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
+2421 -123

No files matched your search

Generated
+13 -13
View File
@@ -3256,7 +3256,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft" name = "solvecraft"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -3270,7 +3270,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-cli" name = "solvecraft-cli"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3279,7 +3279,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-doc" name = "solvecraft-doc"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3294,7 +3294,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-engine" name = "solvecraft-engine"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3310,7 +3310,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-geom" name = "solvecraft-geom"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-io" name = "solvecraft-io"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"quick-xml 0.38.4", "quick-xml 0.38.4",
"solvecraft-doc", "solvecraft-doc",
@@ -3330,7 +3330,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-kernel" name = "solvecraft-kernel"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3345,7 +3345,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-mcp" name = "solvecraft-mcp"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-render" name = "solvecraft-render"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"image", "image",
"serde", "serde",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-sketch" name = "solvecraft-sketch"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3373,7 +3373,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-ui-egui" name = "solvecraft-ui-egui"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"egui", "egui",
"egui-wgpu", "egui-wgpu",
@@ -3388,7 +3388,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-web" name = "solvecraft-web"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -4760,7 +4760,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"] exclude = ["vendor"]
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" 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 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.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 ## View navigation
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping - 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 ## Known limitations
- Some shelled planar faces cannot supply the exact source required for an associative - Associative shell-face sketches require a unique certified planar source. Nested shell
face sketch. The enclosure demo uses parameter-driven offset planes (`width/2` for the attachments and ambiguous coincident sources remain unsupported.
USB port, `wall` for the mounting bosses), so width and wall edits still rebuild the case.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge, - 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 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 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>> }, ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String }, ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 }, Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
} }
impl FaceCertificate { impl FaceCertificate {
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) { 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 if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
&& let Some(mapped) = features.get(sketch) && let Some(mapped) = features.get(sketch)
{ {
@@ -175,6 +184,9 @@ impl FaceCertificate {
&& payload_sha256.len() == 64 && payload_sha256.len() == 64
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) && 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())) } 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) .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))) .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, _ => false,
}; };
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) } 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 /// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order. /// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> { 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::Imported { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. }) | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer, | Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
_ => name _ => name
.strip_prefix('F') .strip_prefix('F')
@@ -316,6 +401,8 @@ impl Document {
let certificate = self.configuration_face_certificate(body, index)?; let certificate = self.configuration_face_certificate(body, index)?;
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate { if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
name = format!("F{origin}:side:{curve}"); 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}:")) { } else if !name.starts_with(&format!("F{origin}:")) {
name = format!("F{origin}:face:{index}"); 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 { let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
return Ok(None); 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 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 { .. })) { if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
return Err(DocError::Invalid("import source producer missing".into())); 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())) format!("{:x}", Sha256::digest(payload.as_bytes()))
} }
#[cfg(test)]
#[path = "config_shell_tests.rs"]
mod shell_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 serde_json::Value;
use solvecraft_geom::Vec3; 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget { pub struct ConfigFaceTarget {
@@ -98,6 +104,20 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
} }
impl Document { 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. /// Certify named generator lineage; generic index-derived naming is unsupported.
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> { pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
target.validate()?; 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 { .. })))) && 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::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, _ => false,
}; };
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) }; 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 { if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into())); 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 { .. })) { if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
let indices: Vec<_> = body let indices: Vec<_> = body
.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)"))), 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) { if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature); *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)?, FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
_ => return Err(DocError::Invalid("blend".into())), _ => return Err(DocError::Invalid("blend".into())),
}; };
let nb = nb.with_preserved_planar_sources(&mb.body)?;
let (name, feat) = (mb.name.clone(), mb.feature); let (name, feat) = (mb.name.clone(), mb.feature);
if let Some(slot) = st.bodies.get_mut(ti) { if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(name, nb, feat); *slot = ModelBody::new(name, nb, feat);
+6 -3
View File
@@ -36,13 +36,16 @@
//! - 12: persistent analytic offset boundary roles and association profile references. //! - 12: persistent analytic offset boundary roles and association profile references.
//! Older analytic chains retain their source/output correspondence and saved geometry; //! Older analytic chains retain their source/output correspondence and saved geometry;
//! migration never invents retired entities or renumbers existing curves. //! 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 serde_json::Value;
use crate::{DocError, Document, Result}; use crate::{DocError, Document, Result};
/// The format this build writes. /// The format this build writes.
pub const FORMAT: u32 = 12; pub const FORMAT: u32 = 13;
pub fn format_string(n: u32) -> String { pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}") 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`. /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>; type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 11] = 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(()), 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. /// 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, /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
+125 -15
View File
@@ -141,15 +141,13 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)), LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => { _ => {
let mut g = Vec::new(); let mut g = Vec::new();
for e in order::face_edges(&mesh, f, plane)? { let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
if mesh.seams.get(e).copied().unwrap_or(false) { for e in projection_face_edges(target, &mesh, f, plane)? {
continue;
}
if let Some(pts) = mesh.edges.get(e) { if let Some(pts) = mesh.edges.get(e) {
g.extend(fit_world(plane, pts)); 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)) section(plane, &mesh, Some(index))
} else { } else {
let mut geom = Vec::new(); let mut geom = Vec::new();
for edge in order::face_edges(&mesh, index, plane)? { for edge in projection_face_edges(target, &mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) { if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points)); 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() { if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into())); 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 }) 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> { 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 body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
let indices = doc.configuration_face_indices(body, &anchor.target)?; let indices = doc.configuration_face_indices(body, &anchor.target)?;
@@ -329,20 +348,30 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
} }
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect(); let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
let mut geometry = Vec::new(); let mut geometry = Vec::new();
if kind == LinkKind::Intersect {
for index in indices { for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?; let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
if kind == LinkKind::Intersect {
geometry.extend(section(plane, &mesh, Some(index))); geometry.extend(section(plane, &mesh, Some(index)));
} else {
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
} }
} 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 {
order::family_edges(&mesh, &selected, plane)?
};
for edge in edges {
if let Some(points) = mesh.edges.get(edge) { if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points)); 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 let point = mesh
.triangles .triangles
@@ -373,6 +402,75 @@ pub fn refresh_face_anchor_links(
refresh_links_in(doc, vals, st, plane, sk, Some(anchor)) 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> { 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) 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) && 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)) { 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; *at = p;
named_face = Some((body.clone(), index, p)); named_face = Some((body.clone(), index, p));
if split { if split {
+103 -1
View File
@@ -10,6 +10,36 @@ use crate::{DocError, Result};
type Key = (i64, i64, i64); type Key = (i64, i64, i64);
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> { 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 bounds = mesh.bounds();
let origin = bounds.min; let origin = bounds.min;
let local_origin = plane.to_local(origin); 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; let p = p - origin;
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64) ((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()))) { 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())); 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 { mod tests {
use super::*; 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 { fn rectangles() -> Mesh {
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] { 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) { fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
s.refresh(); 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 state = s.model.state();
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum(); 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. // 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] #[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] { for hole in [false, true] {
let mut s = Session::default(); 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 { if hole {
run(&mut s, "sketch.create", json!({"plane":"XY"})); run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5})); 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 FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
let generated = s.doc.feature(sketch).unwrap(); let generated = s.doc.feature(sketch).unwrap();
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") }; 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.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 }; let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
check(&mut s, area * 30.0, hole, 30.0); check(&mut s, area * 30.0, hole, 30.0);
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}})); 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(); 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(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.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; let solved = edited.sketch(sketch).unwrap().plane;
assert!((solved.origin.z - 25.0).abs() < 1e-6); assert!((solved.origin.z - 25.0).abs() < 1e-6);
let before = s.model.state(); 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()); let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed); 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] #[test]
fn join_extrude_from_a_chamfered_top_face() { fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default(); 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.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10})); run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "solid.extrude", json!({"distance": 20})); 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": 2})); run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": "bevel"}));
let base = volume(&mut s); let base = volume(&mut s);
let before = s.doc.to_json(); let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}))["sketch"].as_u64().unwrap();
let active = s.active_sketch; let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, sketch } =
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane"); &s.doc.feature(id).unwrap().kind
assert_eq!(s.doc.to_json(), before); else {
assert_eq!(s.active_sketch, active); panic!("the chamfered cap retains its exact original construction support");
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");
}; };
assert!((plane.origin.z - 20.).abs() < 1e-9); assert_eq!(anchor.target.origin_feature, source);
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections"); assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::ExtrudeCap { high: true, .. })));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8})); 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, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"})); run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1); assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4); 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. /// 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}; use crate::{KernelError, Result, guard};
#[path = "planar_regularize.rs"]
mod planar_regularize;
#[path = "planar_sources.rs"]
mod planar_sources;
#[path = "shell_groups.rs"] #[path = "shell_groups.rs"]
mod shell_groups; mod shell_groups;
@@ -33,11 +37,12 @@ pub struct Body {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum FaceSource { 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> }, CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: 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 }, 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 }, 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> }, 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> { fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
match self { match self {
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(), 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, Self::Imported { .. } => None,
} }
} }
@@ -358,6 +365,12 @@ impl Body {
self.face_sources.as_deref().map(Vec::as_slice) 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. /// An exact construction/import plane; never inferred from tessellation.
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> { pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
if index >= 50_000 { if index >= 50_000 {
@@ -366,7 +379,10 @@ impl Body {
let source = self.face_sources()?.get(index)?.as_ref()?; let source = self.face_sources()?.get(index)?.as_ref()?;
match source { match source {
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane, 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), 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, _ => None,
}; };
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis }) Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
} else { } else {
None None
}; };
+22 -8
View File
@@ -116,18 +116,32 @@ fn construction_planes_follow_reflected_and_sheared_affine_images() {
} }
#[test] #[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 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 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(); 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.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!( // Unchanged uniquely certified supports retain their role; a newly cut
source, // support must never inherit the removed opposite wall's source identity.
FaceSource::ExtrudeSide { .. } let mut retained = 0;
| FaceSource::ExtrudeCap { .. } let mut new_wall = 0;
| FaceSource::Box { producer: None, .. } for index in 0..cut.face_count() {
| FaceSource::Imported { producer: None, .. } 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] #[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 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. /// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> { 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")?; b.require_brep("shell")?;
if !(thickness.is_finite() && thickness > 1e-6) { if !(thickness.is_finite() && thickness > 1e-6) {
return Err(KernelError::Invalid("shell thickness must be positive".into())); return Err(KernelError::Invalid("shell thickness must be positive".into()));
+153 -23
View File
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
}; };
slabs.push(oriented(&s)); 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. /// 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>> { 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 { 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; return None;
} }
let mut blocks = Vec::new(); let mut blocks = Vec::new();
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
pieces.push(piece); pieces.push(piece);
} }
} else { } 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()))??; .ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
remaining_faces = remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?; 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. /// 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}; use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does. // 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>> { let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -800,29 +815,60 @@ 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) }, _ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
} }
}; };
let mut faces: Vec<FaceSpec> = Vec::new(); // Exact oriented generated segments alone define a run. No support fitting,
for (i, s) in slabs.iter().enumerate() { // proximity matching, or old face lineage is used here.
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?); let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
for lp in loops_of(s) { 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 sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? { for sg in split_seg(sg0, &pts, tol)? {
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 (a, b) = (sg.start(), sg.end());
let lp3 = vec![ let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
edge(&sg, z0), lp3.push(edge(&sg, z0));
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) }, // Keep station vertices at both ends: neighboring stepped walls/caps
edge(&sg.reversed(), z1), // must share complete edges rather than create T-junctions.
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) }, for k in first..end {
]; lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
let tangent = plane.dir_to_world(sg.derivative(0.5)); }
let outward = tangent.cross(d).normalized()?; 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 { 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) }, 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, _ => SurfSpec::Plane,
}; };
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); 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 (z, up, rs) in &caps {
for r in rs { for r in rs {
@@ -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<_>>()); loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
} }
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); 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>>> { 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) { if slabs.iter().all(Vec::is_empty) {
return Some(Ok(None)); 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>>> { 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(21., 21., 3.)));
assert!(!mesh.contains(Vec3::new(25., 25., 3.))); assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
assert!(mesh.contains(Vec3::new(25., 25., 22.))); 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); 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(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof); 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] #[test]
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() { fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
let plane = Plane::XY; let plane = Plane::XY;
+17 -6
View File
@@ -97,13 +97,16 @@ impl Cache {
/// Sew the faces into a closed solid. /// Sew the faces into a closed solid.
pub fn sew(faces: &[FaceSpec]) -> Result<Body> { 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. /// Separate boundary sheets at a shared geometric edge without moving it.
/// The face adjacency must still describe one connected solid boundary. /// Optional transient sources follow only unchanged produced face identities.
pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result<Body> { pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option<crate::FaceSource>]) -> Result<Body> {
sew_impl(faces, true) if sources.len() != faces.len() {
return Err(KernelError::Invalid("sew source alignment".into()));
}
sew_impl(faces, true, Some(sources))
} }
type EdgeUse = (usize, usize, usize); type EdgeUse = (usize, usize, usize);
@@ -347,7 +350,7 @@ fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
.collect() .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 { if faces.len() < 4 || faces.len() > 200_000 {
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into())); 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); 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) { if !sheets.is_empty() && !faces_connected(&out) {
return Err(KernelError::Invalid("sew: bodies touch only along an edge".into())); 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 shells = closed_components(out)?;
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; 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))))
}) })
} }
+10 -2
View File
@@ -1273,7 +1273,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
update_fill_cells(app, &mut d); update_fill_cells(app, &mut d);
let t = Tokens::get(); let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE); let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((vp.bottom() - 360.0).max(vp.top()));
let anchor = egui::pos2(vp.right(), top);
let mut keep = true; let mut keep = true;
let mut ok = false; let mut ok = false;
let mut applied = false; let mut applied = false;
@@ -1303,7 +1304,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
width, width,
) )
.frame(frame) .frame(frame)
.scroll([false, true])
.open(&mut keep) .open(&mut keep)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.spacing_mut().text_edit_width = FIELD_W; ui.spacing_mut().text_edit_width = FIELD_W;
@@ -1332,6 +1332,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return; return;
} }
ui.add_space(2.0); ui.add_space(2.0);
// A sizing pass initially offers almost no height to an auto-sized window.
// Give only the fields a viewport-derived scroll budget; header and actions
// stay visible, while short forms still shrink to their natural height.
let vertical_frame = frame.inner_margin.topf() + frame.inner_margin.bottomf() + 2.0 * frame.stroke.width;
let footer_height = ui.spacing().interact_size.y.max(24.0) + 6.0 + ui.spacing().item_spacing.y;
let fields_height = (vp.bottom() - anchor.y - 8.0 - vertical_frame - ui.min_rect().height() - footer_height).max(1.0);
egui::ScrollArea::vertical().id_salt("feature_fields").min_scrolled_height(fields_height).max_height(fields_height).show(ui, |ui| {
// Configuration tables own their column widths and scrolling; the two-column form // Configuration tables own their column widths and scrolling; the two-column form
// grid would squeeze their row names and action buttons into a label-sized cell. // grid would squeeze their row names and action buttons into a label-sized cell.
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind { if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
@@ -1923,6 +1930,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row(); ui.end_row();
} }
}); });
});
ui.add_space(6.0); ui.add_space(6.0);
// Footer: an info glyph at the left, OK and Cancel at the right. // Footer: an info glyph at the left, OK and Cancel at the right.
ui.horizontal(|ui| { ui.horizontal(|ui| {
+33 -4
View File
@@ -1023,6 +1023,29 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm")); 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] #[test]
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() { fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
let mut h = crate::scenario::Harness::new(); let mut h = crate::scenario::Harness::new();
@@ -1032,9 +1055,11 @@ mod tests {
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}}, {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"},{"widget":"Add or edit rule"}, {"start":"config.table"},{"widget":"Add or edit rule"},
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"}, {"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"} {"widget":"Choose…"}
])) ]))
.unwrap(); .unwrap();
select_rule_parameter(&mut h, "length");
h.step(&json!({"widget":"Save Rule"})).unwrap();
assert!( 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{..})) 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(); ])).unwrap();
assert!(h.widget("length = 20 mm").is_some()); assert!(h.widget("length = 20 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); 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_eq!(h.app.session.doc.configs.rules[0].id, "last");
assert!(h.widget("length = 10 mm").is_some()); assert!(h.widget("length = 10 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); 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"]}}}, {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"}, {"start":"config.table"},
{"widget":"Add or edit rule"}, {"widget":"Add or edit rule"},
{"widget":"Choose…"}, {"widget":"Choose…"}
{"widget":"length"}, ]))
.unwrap();
select_rule_parameter(&mut h, "length");
h.run(&json!([
{"widget":"Save Rule"} {"widget":"Save Rule"}
])) ]))
.unwrap(); .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)] #[cfg(test)]
mod tests { 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. /// Windows are made in this file only, so none can skip the width limits.
#[test] #[test]
fn windows_come_from_the_frame_helper() { fn windows_come_from_the_frame_helper() {
+2
View File
@@ -78,6 +78,8 @@ mod revolve_edit_tests;
mod revolve_manipulator_tests; mod revolve_manipulator_tests;
pub mod scenario; pub mod scenario;
pub mod selection; pub mod selection;
#[cfg(test)]
mod shell_face_sketch_ui_tests;
pub mod shortcut_box; pub mod shortcut_box;
pub mod sketch3d; pub mod sketch3d;
#[cfg(test)] #[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(); let mut h = Harness::new();
h.app.ui.show_origin = false; 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.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 before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len(); let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"top"})); h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"})); h.call("ui.view", json!({"view":"fit"}));
h.settle(); h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap(); 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.session.active_sketch.is_none());
assert!(h.app.dialog.as_ref().unwrap().error.is_some()); assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!(h.app.session.doc.to_json(), before); 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 { pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; 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 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); // Navigation is anchored to the viewport, never to transient dialog space.
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 { 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)) Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
} else { } else {
Rect::NOTHING Rect::NOTHING
}; };
if axis_view.is_positive() { let nav_space = viewport;
obstacles.push(axis_view);
}
let nav_space = free_rect(viewport, &obstacles);
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0); 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 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 { 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 { } else {
Rect::NOTHING 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() { if navigation.is_positive() {
obstacles.push(navigation); 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_eq!(outer, layout.axis_view);
assert!(outer.contains_rect(home)); assert!(outer.contains_rect(home));
assert!(layout.viewport.contains_rect(outer)); 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()); assert!(!layout.navigation.intersect(outer).is_positive());
for name in ["x", "-x", "y", "-y", "z", "-z"] { for name in ["x", "-x", "y", "-y", "z", "-z"] {
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap(); 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.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo); 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": { "expect": {
"sketching": false, "sketching": true,
"dialog": "this face has no exact planar source" "dialog": null,
} "errors": 0
},
{
"widget": "Use fixed plane"
},
{
"expect": {
"sketching": true
} }
}, },
{ {