fix: restore shell and cut wall topology in 0.2.1
This commit is contained in:
1 parent
1aedbf6631
commit
ef8f6ca6f9
31 files changed
+2421
-123
No files matched your search
Generated
+13
-13
@@ -3256,7 +3256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3270,7 +3270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3279,7 +3279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3294,7 +3294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3310,7 +3310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3318,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3330,7 +3330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3345,7 +3345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3353,7 +3353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3362,7 +3362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3373,7 +3373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3388,7 +3388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4760,7 +4760,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+23
-3
@@ -90,6 +90,27 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
@@ -99,9 +120,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Some shelled planar faces cannot supply the exact source required for an associative
|
||||
face sketch. The enclosure demo uses parameter-driven offset planes (`width/2` for the
|
||||
USB port, `wall` for the mounting bosses), so width and wall edits still rebuild the case.
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
|
||||
@@ -154,10 +154,19 @@ pub enum FaceCertificate {
|
||||
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
|
||||
if let Self::ShellPlanar { source_producer, source, .. } = self {
|
||||
if let Some(mapped) = features.get(source_producer) {
|
||||
*source_producer = *mapped;
|
||||
}
|
||||
if !matches!(source.as_ref(), Self::ShellPlanar { .. }) {
|
||||
source.remap_features(features);
|
||||
}
|
||||
}
|
||||
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
|
||||
&& let Some(mapped) = features.get(sketch)
|
||||
{
|
||||
@@ -175,6 +184,9 @@ impl FaceCertificate {
|
||||
&& payload_sha256.len() == 64
|
||||
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
Self::ShellPlanar { source_producer, source, .. } => {
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
|
||||
}
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
@@ -216,11 +228,83 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
||||
}
|
||||
Some("shell_planar") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
|
||||
&& nonzero("source_producer")
|
||||
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& fields.get("source").is_some_and(|source| {
|
||||
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
|
||||
&& validate_certificate_json(source).is_ok()
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
|
||||
}
|
||||
|
||||
/// Canonical diagnostic role; producer identity remains in the typed certificate.
|
||||
pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> Result<String> {
|
||||
source.validate()?;
|
||||
let role = match source {
|
||||
FaceCertificate::Box { axis, high } => {
|
||||
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
|
||||
format!("box:{}{axis}", if *high { "+" } else { "-" })
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
|
||||
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
|
||||
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
|
||||
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
|
||||
};
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_certificate(source: &solvecraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match source {
|
||||
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
|
||||
}
|
||||
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }))
|
||||
}
|
||||
FaceSource::Imported { producer: Some(producer), payload_sha256: Some(digest), item, face } => {
|
||||
Some((*producer, FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvecraft_kernel::FaceSource, producer: u64) -> bool {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match (certificate, source) {
|
||||
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
|
||||
producer == *current && axis == current_axis && high == current_high
|
||||
}
|
||||
(
|
||||
FaceCertificate::ExtrudeSide { sketch, curve },
|
||||
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
|
||||
) => producer == *current && sketch == current_sketch && curve == current_curve,
|
||||
(
|
||||
FaceCertificate::ExtrudeCap { sketch, high, outer, holes },
|
||||
FaceSource::CertifiedExtrudeCap {
|
||||
producer: current,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
},
|
||||
) => producer == *current && sketch == current_sketch && high == current_high && outer == current_outer && holes == current_holes,
|
||||
(
|
||||
FaceCertificate::Imported { payload_sha256, item, face },
|
||||
FaceSource::Imported { producer: Some(current), payload_sha256: Some(digest), item: current_item, face: current_face },
|
||||
) => producer == *current && payload_sha256 == digest && item == current_item && face == current_face,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a generated segment to one exact analytic sketch curve. Coincident
|
||||
/// candidates are ambiguous and never resolved by distance or candidate order.
|
||||
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
|
||||
@@ -306,6 +390,7 @@ impl Document {
|
||||
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
@@ -316,6 +401,8 @@ impl Document {
|
||||
let certificate = self.configuration_face_certificate(body, index)?;
|
||||
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
|
||||
name = format!("F{origin}:side:{curve}");
|
||||
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
|
||||
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
|
||||
} else if !name.starts_with(&format!("F{origin}:")) {
|
||||
name = format!("F{origin}:face:{index}");
|
||||
}
|
||||
@@ -332,6 +419,19 @@ impl Document {
|
||||
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source, inner, .. } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Shell { .. })) {
|
||||
return Err(DocError::Invalid("shell source producer missing".into()));
|
||||
}
|
||||
let (source_producer, source) =
|
||||
flat_source_certificate(source).ok_or_else(|| DocError::Invalid("shell has no flat certified source".into()))?;
|
||||
if !self.flat_certificate_producer_matches(source_producer, &source) {
|
||||
return Err(DocError::Invalid("shell original source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::ShellPlanar { source_producer, source: Box::new(source), inner: *inner };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
|
||||
return Err(DocError::Invalid("import source producer missing".into()));
|
||||
@@ -378,6 +478,10 @@ pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_shell_tests.rs"]
|
||||
mod shell_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
use crate::{FeatureKind, Model, PlaneRef};
|
||||
use serde_json::json;
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
fn shell_document() -> (Document, u64, u64) {
|
||||
let mut doc = Document::new("certified shell");
|
||||
doc.set_param("width", "20", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":[0,0,0],"length":"width","width":"12","height":"10"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(5., 6., 10.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.unwrap();
|
||||
(doc, base, shell)
|
||||
}
|
||||
|
||||
fn face_index(body: &ModelBody, inner: bool) -> usize {
|
||||
body.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: current, source, .. })
|
||||
if *current == inner && matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis:0,high:true,.. }))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_raw_bounds_flat_identity_and_clone_remap() {
|
||||
let plane = solvecraft_geom::Plane::XY;
|
||||
let same = |planes: &[Option<solvecraft_geom::Plane>]| super::super::styles::shell_plane_family(planes.iter().copied());
|
||||
assert!(same(&[Some(plane), Some(plane)]));
|
||||
assert!(!same(&[]));
|
||||
assert!(!same(&[Some(plane), None]));
|
||||
for different in [
|
||||
solvecraft_geom::Plane { origin: Vec3::X, ..plane },
|
||||
solvecraft_geom::Plane { x: -plane.x, y: -plane.y, ..plane },
|
||||
solvecraft_geom::Plane { y: -plane.y, ..plane },
|
||||
] {
|
||||
assert!(!same(&[Some(plane), Some(different)]));
|
||||
}
|
||||
let valid = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"c4"},"inner":true});
|
||||
assert!(validate_certificate_json(&valid).is_ok());
|
||||
let mut certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
|
||||
certificate.remap_features(&[(2, 20), (3, 30)].into_iter().collect());
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source_producer"], 30);
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source"]["sketch"], 20);
|
||||
for bad in [
|
||||
json!({"kind":"shell_planar","source_producer":0,"source":{"kind":"box","axis":0,"high":true},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":null,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":valid,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"x".repeat(1_000_000)},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"box","axis":0,"high":true,"extra":[[[]]]},"inner":false}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
let (mut doc, base, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let outer = doc.strong_face_target(body, face_index(body, false), Vec3::default()).unwrap();
|
||||
let outer_matches: Vec<_> = body.body.face_sources().unwrap().iter().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: false, source, .. }) if matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis: 0, high: true, .. })).then_some((index, body.body.face_plane(index)))
|
||||
}).collect();
|
||||
assert_eq!(outer_matches.len(), 1, "outer support must be one physical face: {outer_matches:?}");
|
||||
assert!(super::super::styles::shell_plane_family(outer_matches.iter().map(|(_, plane)| *plane)));
|
||||
let outer_plane = body.body.face_plane(face_index(body, false)).unwrap();
|
||||
let outer_anchor = doc.capture_face_anchor(body, face_index(body, false), &outer_plane, &[], 0, &[]).unwrap();
|
||||
let projected = crate::project::resolve_anchored_face(&doc, &state, &outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
let mut linked = solvecraft_sketch::Sketch::new();
|
||||
let link = linked.add_link(solvecraft_sketch::LinkKind::Project, projected.source, &projected.geom).unwrap();
|
||||
assert!(!linked.update_link(&link, &projected.geom).unwrap());
|
||||
let inner_index = face_index(body, true);
|
||||
let inner = doc.strong_face_target(body, inner_index, Vec3::default()).unwrap();
|
||||
assert_ne!(outer.key(), inner.key());
|
||||
assert!(matches!(&inner.certificate, Some(FaceCertificate::ShellPlanar { source_producer,inner:true,.. }) if *source_producer==base));
|
||||
assert_eq!(inner.origin_feature, shell);
|
||||
let picked = body.body.face_plane(inner_index).unwrap();
|
||||
assert!((picked.origin.x - 18.).abs() < 1e-8);
|
||||
let anchor = doc.capture_face_anchor(body, inner_index, &picked, &[], 0, &[]).unwrap();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].into_iter().map(|(x, y)| sketch.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) },
|
||||
sketch,
|
||||
},
|
||||
Some("Inner face sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Face boss"),
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&doc);
|
||||
let before = model.state().bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
doc.set_param("width", "30", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
let plane = doc.resolve_face_anchor(&anchor, 0, &changed).unwrap();
|
||||
assert!((plane.origin.x - 27.).abs() < 1e-8);
|
||||
let after = changed.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!((solvecraft_kernel::measure(&changed.bodies.iter().find(|body| body.feature == boss).unwrap().body).unwrap().volume - 8.).abs() < 1e-8);
|
||||
assert!((doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap().origin.x - 30.).abs() < 1e-8);
|
||||
let changed_outer_plane = doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap();
|
||||
let refreshed =
|
||||
crate::project::resolve_anchored_face(&doc, &changed, &changed_outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
assert!(!linked.update_link(&link, &refreshed.geom).unwrap(), "same exterior boundary must retain projected IDs");
|
||||
assert!((after.min.x - before.min.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
|
||||
let saved = doc.to_json();
|
||||
assert!(saved.contains("solvecraft/13"));
|
||||
let loaded = crate::format::read(&saved).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
let cold_state = cold.state();
|
||||
let cold_bounds = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(after.min.dist(cold_bounds.min) < 1e-9 && after.max.dist(cold_bounds.max) < 1e-9);
|
||||
assert!(loaded.resolve_face_anchor(&anchor, 0, &cold_state).unwrap().origin.dist(plane.origin) < 1e-9);
|
||||
let mut wrong = anchor.clone();
|
||||
wrong.target.origin_feature = base;
|
||||
assert!(doc.resolve_face_anchor(&wrong, 0, &changed).is_err());
|
||||
let mut wrong_source = anchor.clone();
|
||||
if let Some(FaceCertificate::ShellPlanar { source_producer, .. }) = &mut wrong_source.target.certificate {
|
||||
*source_producer = shell;
|
||||
}
|
||||
assert!(doc.resolve_face_anchor(&wrong_source, 0, &changed).is_err());
|
||||
doc.feature_mut(shell).unwrap().suppressed = true;
|
||||
model.evaluate(&doc);
|
||||
let lost = model.state();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &lost).is_err());
|
||||
assert!(!lost.bodies.iter().any(|body| body.feature == boss), "lost attachment must prevent downstream geometry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
let (doc, _, _) = shell_document();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let loaded = crate::format::read(&legacy.to_string()).unwrap();
|
||||
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
|
||||
assert_eq!(current["format"], "solvecraft/13");
|
||||
current["format"] = json!("solvecraft/12");
|
||||
assert_eq!(legacy, current, "identity migration must preserve every prior field");
|
||||
legacy["format"] = json!("solvecraft/14");
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed() {
|
||||
let (mut doc, _, _) = shell_document();
|
||||
let mut model = Model::new();
|
||||
for (width, wall) in [(20., 2.), (30., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for state in [model.state(), cold.state()] {
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let mesh = body.mesh();
|
||||
assert_eq!(body.body.face_sources().unwrap().iter().filter(|source|matches!(source,Some(solvecraft_kernel::FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),solvecraft_kernel::FaceSource::Box {axis:0,high:true,..}))).count(),1,"no physical outer-wall strip face may remain");
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(tri, face)| (*face as usize == index).then(|| mesh.tri(tri)).flatten())
|
||||
.map(|[a, b, c]| (a + b + c) / 3.)
|
||||
.unwrap();
|
||||
let source = solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: point };
|
||||
let (values, _) = doc.param_values();
|
||||
let legacy = crate::project::resolve(&doc, &values, &state, &plane, solvecraft_sketch::LinkKind::Project, &source).unwrap();
|
||||
let explicit = crate::project::resolve_face_indices(&state, &plane, solvecraft_sketch::LinkKind::Project, "Case", &[index]).unwrap();
|
||||
assert_eq!(legacy.geom, explicit.geom);
|
||||
assert_eq!(explicit.geom.len(), 4, "raw planar piece keeps all four edges");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let link = sketch.add_link(solvecraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
|
||||
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
|
||||
let (mut doc, _, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let stem = crate::naming::face_names(body)[index].clone();
|
||||
assert_eq!(crate::naming::strip_piece(&stem), stem);
|
||||
// Reconstruct the original raw shell before certificate stamping/regularization,
|
||||
// and run the unchanged historical naming/capture path on its actual strip.
|
||||
let before = model.state_before(shell);
|
||||
// Legacy Shell did not propagate construction certificates. Omit transient
|
||||
// metadata here; operand paint also disables new wall-run coalescence so
|
||||
// this control retains the actual historical raw pieces.
|
||||
let raw_source = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(20., 12., 10.)).unwrap().with_paint(Some(solvecraft_kernel::Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![solvecraft_kernel::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }],
|
||||
}));
|
||||
let raw = solvecraft_kernel::shell(&raw_source, &[Vec3::new(5., 6., 10.)], 2.).unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
|
||||
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
|
||||
sketch
|
||||
.add_link(
|
||||
solvecraft_sketch::LinkKind::Project,
|
||||
solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: Vec3::new(20., 6., 1.) },
|
||||
&geometry,
|
||||
)
|
||||
.unwrap();
|
||||
sketch.links[0].face_name = Some(former.clone());
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
|
||||
Some("Saved strip sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved strip profile material"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let mut doc = crate::format::read(&legacy.to_string()).unwrap();
|
||||
for width in [20., 30.] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
|
||||
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
|
||||
"old24mm3 profile must not silently become120mm3 wall profile"
|
||||
);
|
||||
let bounds = material.mesh().bounds();
|
||||
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
|
||||
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
use solvecraft_kernel::{FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("saved curved strip");
|
||||
doc.set_param("width", "80", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":["-width/2",-30,0],"length":"width","width":"60","height":"30"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
doc.add_feature(
|
||||
serde_json::from_value(json!({"type":"fillet","body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":"6"}))
|
||||
.unwrap(),
|
||||
Some("Corners"),
|
||||
)
|
||||
.unwrap();
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(0., 0., 30.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(shell);
|
||||
// Per-face paint disables the construction optimization: this control keeps
|
||||
// the real pre-repair slab faces without forging a name or source witness.
|
||||
let old_source = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let cavity_box = solvecraft_kernel::box_solid(Vec3::new(-38., -28., 2.), Vec3::new(38., 28., 35.)).unwrap();
|
||||
let cavity = solvecraft_kernel::fillet(
|
||||
&cavity_box,
|
||||
&[Vec3::new(38., 28., 15.), Vec3::new(-38., 28., 15.), Vec3::new(-38., -28., 15.), Vec3::new(38., -28., 15.)],
|
||||
4.,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = solvecraft_kernel::boolean(&old_source, &cavity, solvecraft_kernel::BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let t = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
let stem = crate::naming::strip_piece(&former).to_string();
|
||||
assert_ne!(former, stem, "actual old cylindrical strip must have a piece suffix");
|
||||
let state = model.state();
|
||||
let current = state.body("Case").unwrap();
|
||||
let names = crate::naming::face_names(current);
|
||||
let index = names.iter().position(|name| name == &stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(index));
|
||||
assert!(current.body.face_plane(index).is_none(), "witness grants no planar attachment authority");
|
||||
assert!(old_body.body.face_sources().is_none_or(|sources| sources.iter().enumerate().all(|(index, _)| !old_body.body.face_was_coalesced(index))));
|
||||
let mut old_state = (*model.state()).clone();
|
||||
old_state.bodies = vec![old_body];
|
||||
let source = LinkSource::Face { body: "Case".into(), at };
|
||||
// Recover the saved projection from the old face's complete raw topology.
|
||||
// Display seam suppression must not discard a historical strip boundary.
|
||||
let old_body = old_state.body("Case").unwrap();
|
||||
let old_index = crate::naming::face_on_body(old_body, &former, at).unwrap().0;
|
||||
let mesh = old_body.mesh();
|
||||
let mut geometry = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
if !faces.contains(&(old_index as u32)) {
|
||||
continue;
|
||||
}
|
||||
let points = &mesh.edges[index];
|
||||
let a = solvecraft_geom::Plane::YZ.to_local(*points.first().unwrap());
|
||||
let b = solvecraft_geom::Plane::YZ.to_local(*points.last().unwrap());
|
||||
let (a, b) = (Vec2::new(a.x, a.y), Vec2::new(b.x, b.y));
|
||||
assert!(a.dist(b) > 1e-6);
|
||||
for point in points {
|
||||
let q = solvecraft_geom::Plane::YZ.to_local(*point);
|
||||
let q = Vec2::new(q.x, q.y);
|
||||
let direction = b - a;
|
||||
let t = ((q - a).dot(direction) / direction.dot(direction)).clamp(0., 1.);
|
||||
assert!(q.dist(a + direction * t) < 1e-7, "exact cylindrical quadrant projects to a straight support");
|
||||
}
|
||||
geometry.push(solvecraft_sketch::LinkGeom::Line(a, b));
|
||||
}
|
||||
assert_eq!(geometry.len(), 4, "historical curved strip has a complete four-edge rectangular projection");
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solvecraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Retained strip material"),
|
||||
)
|
||||
.unwrap();
|
||||
for (width, wall) in [(80., 2.), (100., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none());
|
||||
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
|
||||
"two-mm strip cannot expand to a full thirty-mm wall"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solvecraft_geom::Plane>>) -> bool {
|
||||
let mut planes = planes.into_iter();
|
||||
let Some(Some(first)) = planes.next() else { return false };
|
||||
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFaceTarget {
|
||||
@@ -98,6 +104,20 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
|
||||
use super::FaceCertificate;
|
||||
let Some(feature) = self.feature(producer) else { return false };
|
||||
match (certificate, &feature.kind) {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == current && extent.taper.is_none()
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Certify named generator lineage; generic index-derived naming is unsupported.
|
||||
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
|
||||
target.validate()?;
|
||||
@@ -121,6 +141,11 @@ impl Document {
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
|
||||
*source_producer != target.origin_feature
|
||||
&& self.flat_certificate_producer_matches(*source_producer, source)
|
||||
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
|
||||
@@ -156,6 +181,17 @@ impl Document {
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
|
||||
matches!(current, Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
|
||||
}).collect();
|
||||
// A shell can split one original support into several pieces. All pieces
|
||||
// must retain the same canonical frame, not merely lie on a common plane.
|
||||
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
|
||||
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
|
||||
@@ -2910,6 +2910,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
|
||||
};
|
||||
let nb = nb.with_shell_certificate(f.id)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
||||
}
|
||||
@@ -3159,6 +3160,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
|
||||
_ => return Err(DocError::Invalid("blend".into())),
|
||||
};
|
||||
let nb = nb.with_preserved_planar_sources(&mb.body)?;
|
||||
let (name, feat) = (mb.name.clone(), mb.feature);
|
||||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||||
*slot = ModelBody::new(name, nb, feat);
|
||||
|
||||
@@ -36,13 +36,16 @@
|
||||
//! - 12: persistent analytic offset boundary roles and association profile references.
|
||||
//! Older analytic chains retain their source/output correspondence and saved geometry;
|
||||
//! migration never invents retired entities or renumbers existing curves.
|
||||
//! - 13: exact planar shell face anchors retain the shell producer, original certified
|
||||
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
|
||||
//! migration never guesses attachments for older designs.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 12;
|
||||
pub const FORMAT: u32 = 13;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -55,8 +58,8 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 11] =
|
||||
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
const JSON_STEPS: [JsonStep; 12] =
|
||||
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
|
||||
+125
-15
@@ -141,15 +141,13 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
for e in order::face_edges(&mesh, f, plane)? {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
for e in projection_face_edges(target, &mesh, f, plane)? {
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
g
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,15 +291,13 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
section(plane, &mesh, Some(index))
|
||||
} else {
|
||||
let mut geom = Vec::new();
|
||||
for edge in order::face_edges(&mesh, index, plane)? {
|
||||
if mesh.seams.get(edge).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
for edge in projection_face_edges(target, &mesh, index, plane)? {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geom.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
merge_split_circles(geom)
|
||||
let geom = merge_split_circles(geom);
|
||||
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
|
||||
};
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
@@ -309,6 +305,29 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
}
|
||||
|
||||
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let shell_plane = body
|
||||
.body
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index as usize))
|
||||
.is_some_and(|source| matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { .. })));
|
||||
if shell_plane { order::raw_face_edges(mesh, index, plane) } else { order::face_edges(mesh, index, plane) }
|
||||
}
|
||||
|
||||
fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool {
|
||||
body.body.face_sources().and_then(|sources| sources.get(index)).is_some_and(|source| {
|
||||
matches!(
|
||||
source,
|
||||
Some(
|
||||
solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::Box { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
|
||||
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
|
||||
let indices = doc.configuration_face_indices(body, &anchor.target)?;
|
||||
@@ -329,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 mut geometry = Vec::new();
|
||||
if kind == LinkKind::Intersect {
|
||||
for index in indices {
|
||||
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
|
||||
if kind == LinkKind::Intersect {
|
||||
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) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
|
||||
geometry = merge_family_lines(geometry)?;
|
||||
}
|
||||
let point = mesh
|
||||
.triangles
|
||||
@@ -373,6 +402,75 @@ pub fn refresh_face_anchor_links(
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
/// The walk uses endpoint incidence; geometric sorting does not imply adjacency.
|
||||
fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
if geom.len().saturating_mul(geom.len()).saturating_mul(3) > 10_000_000 {
|
||||
return Err(DocError::Invalid("face family straight-boundary work limit exceeded".into()));
|
||||
}
|
||||
let ends = |curve: &LinkGeom| match curve {
|
||||
LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((*a, *b)),
|
||||
LinkGeom::Spline { pts, periodic: false, .. } => pts.first().zip(pts.last()).map(|(a, b)| (*a, *b)),
|
||||
_ => None,
|
||||
};
|
||||
let scale = geom.iter().filter_map(&ends).flat_map(|(a, b)| [a.len(), b.len()]).fold(1., f64::max);
|
||||
let tol = scale * f64::EPSILON * 512.;
|
||||
let mut used = vec![false; geom.len()];
|
||||
let mut result = Vec::new();
|
||||
for (index, curve) in geom.iter().enumerate() {
|
||||
if used.get(index).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(index) {
|
||||
*slot = true;
|
||||
}
|
||||
let LinkGeom::Line(a, b) = curve else {
|
||||
result.push(curve.clone());
|
||||
continue;
|
||||
};
|
||||
let mut endpoints = [*a, *b];
|
||||
for side in 0..2 {
|
||||
let mut current = index;
|
||||
for _ in 0..geom.len() {
|
||||
let Some(point) = endpoints.get(side).copied() else { break };
|
||||
let mut incidence = geom
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(other, curve)| ends(curve).filter(|(a, b)| a.dist(point) <= tol || b.dist(point) <= tol).map(|_| other));
|
||||
let (Some(one), Some(two), None) = (incidence.next(), incidence.next(), incidence.next()) else { break };
|
||||
let other = if one == current {
|
||||
two
|
||||
} else if two == current {
|
||||
one
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
if used.get(other).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
let Some(LinkGeom::Line(a, b)) = geom.get(other) else { break };
|
||||
let next = if a.dist(point) <= tol { *b } else { *a };
|
||||
let Some(opposite) = endpoints.get(1 - side).copied() else { break };
|
||||
let (Some(incoming), Some(outgoing)) = ((point - opposite).normalized(), (next - point).normalized()) else { break };
|
||||
if incoming.dot(outgoing) <= 0. || incoming.cross(outgoing).abs() > f64::EPSILON * 512. {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(other) {
|
||||
*slot = true;
|
||||
}
|
||||
if let Some(slot) = endpoints.get_mut(side) {
|
||||
*slot = next;
|
||||
}
|
||||
current = other;
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (endpoints.first(), endpoints.get(1)) {
|
||||
result.push(LinkGeom::Line(*a, *b));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
}
|
||||
@@ -394,6 +492,18 @@ fn refresh_links_in(
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
|
||||
let stem = crate::naming::strip_piece(name);
|
||||
let removed_piece = split
|
||||
&& name != stem
|
||||
&& st.body(body).is_some_and(|target| {
|
||||
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
|
||||
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
|
||||
});
|
||||
if removed_piece {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
|
||||
continue;
|
||||
}
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
if split {
|
||||
|
||||
@@ -10,6 +10,36 @@ use crate::{DocError, Result};
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
/// Full boundary of an explicitly selected analytic planar piece. Display seams
|
||||
/// hide subdivisions for picking, but cannot remove a raw piece's closing edge.
|
||||
pub(super) fn raw_face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
boundary_edges(mesh, mesh.face_edges(face), plane)
|
||||
}
|
||||
|
||||
/// Exterior of an exact current face family. Shared topological edges are internal;
|
||||
/// geometric proximity never merges distinct edges or chooses a different face.
|
||||
pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let mut edges = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
let count = faces.iter().filter(|face| selected.contains(&(**face as usize))).count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if faces.len() > 2 || (faces.len() == 2 && faces.first() == faces.get(1)) {
|
||||
return Err(DocError::Invalid("face family has ambiguous edge adjacency".into()));
|
||||
}
|
||||
if count == 1 && !mesh.seams.get(index).copied().unwrap_or(false) {
|
||||
edges.push(index);
|
||||
}
|
||||
}
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let bounds = mesh.bounds();
|
||||
let origin = bounds.min;
|
||||
let local_origin = plane.to_local(origin);
|
||||
@@ -21,7 +51,6 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
|
||||
let p = p - origin;
|
||||
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
|
||||
};
|
||||
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
|
||||
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
|
||||
}
|
||||
@@ -119,6 +148,79 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn face_family_omits_only_exact_shared_topological_edges() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (a, b, faces) in [
|
||||
((0., 0.), (2., 0.), vec![0]),
|
||||
((2., 0.), (2., 1.), vec![0]),
|
||||
((2., 1.), (0., 1.), vec![0, 1]),
|
||||
((0., 1.), (0., 0.), vec![0]),
|
||||
((2., 1.), (2., 2.), vec![1]),
|
||||
((2., 2.), (0., 2.), vec![1]),
|
||||
((0., 2.), (0., 1.), vec![1]),
|
||||
] {
|
||||
mesh.edges.push(vec![Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.positions.extend([Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.edge_faces.push(faces);
|
||||
}
|
||||
let selected = [0, 1].into_iter().collect();
|
||||
let edges = family_edges(&mesh, &selected, &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 6);
|
||||
assert!(!edges.contains(&2));
|
||||
let geometry: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
assert_eq!(super::super::merge_family_lines(geometry).unwrap().len(), 4);
|
||||
assert_eq!(family_edges(&mesh, &[0].into_iter().collect(), &Plane::XY).unwrap().len(), 4);
|
||||
mesh.edge_faces[2] = vec![0, 1, 2];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
mesh.edge_faces[2] = vec![0, 0];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_crossing_boundaries_are_not_geometrically_cancelled() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] {
|
||||
let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 2., y, 0.), Vec3::new(x + 2., y + 2., 0.), Vec3::new(x, y + 2., 0.)];
|
||||
mesh.positions.extend(corners);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![face]);
|
||||
}
|
||||
}
|
||||
let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 8);
|
||||
let geom: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
let mut sk = solvecraft_sketch::Sketch::new();
|
||||
let link = sk
|
||||
.add_link(solvecraft_sketch::LinkKind::Project, solvecraft_sketch::LinkSource::Face { body: "ambiguous".into(), at: Vec3::ZERO }, &geom)
|
||||
.unwrap();
|
||||
assert!(sk.update_link(&link, &geom).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_line_normalization_preserves_corners_arcs_branches_and_retracing() {
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let line = |a, b| LinkGeom::Line(Vec2::new(a, 0.), Vec2::new(b, 0.));
|
||||
assert_eq!(super::super::merge_family_lines(vec![line(0., 1.), line(2., 1.)]).unwrap(), vec![line(0., 2.)]);
|
||||
for geometry in [
|
||||
vec![line(0., 1.), line(1., 0.5)],
|
||||
vec![line(0., 1.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(2., 1e-8))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(1., 1.))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Conic { a: Vec2::new(1., 0.), apex: Vec2::new(2., 1.), b: Vec2::new(3., 1.), rho: 0.5 }],
|
||||
vec![
|
||||
line(0., 1.),
|
||||
line(1., 2.),
|
||||
LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false },
|
||||
],
|
||||
vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }],
|
||||
vec![line(0., 1.), line(1.00001, 2.)],
|
||||
] {
|
||||
assert_eq!(super::super::merge_family_lines(geometry.clone()).unwrap(), geometry);
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangles() -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
|
||||
|
||||
@@ -10,7 +10,7 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
}
|
||||
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
|
||||
@@ -22,10 +22,10 @@ fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() {
|
||||
fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}));
|
||||
let base = run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}))["feature"].as_u64().unwrap();
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
@@ -37,9 +37,11 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
|
||||
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
|
||||
let generated = s.doc.feature(sketch).unwrap();
|
||||
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
|
||||
assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole));
|
||||
let PlaneRef::Face { anchor: Some(anchor), .. } = plane else { panic!("the original Box cap remains exact after a hole cut") };
|
||||
assert_eq!(anchor.target.origin_feature, base);
|
||||
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::Box { axis: 2, high: true })));
|
||||
assert!(!profile.links.is_empty());
|
||||
assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost));
|
||||
assert!(profile.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
|
||||
@@ -48,7 +50,7 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
|
||||
let edited = s.model.state();
|
||||
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
|
||||
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
|
||||
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
let solved = edited.sketch(sketch).unwrap().plane;
|
||||
assert!((solved.origin.z - 25.0).abs() < 1e-6);
|
||||
let before = s.model.state();
|
||||
|
||||
@@ -399,3 +399,184 @@ fn angled_attachment_tracks_source_plane_and_reopens() {
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_enclosure_shell_side_and_floor_attach_through_width_wall_and_reopen() {
|
||||
use solvecraft_doc::{FeatureKind, config::FaceCertificate, face_anchor::plane_face_anchor};
|
||||
let mut s = Session::default();
|
||||
for (name, expression) in [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")] {
|
||||
run(&mut s, "parameters.add", json!({"name":name,"expression":expression}));
|
||||
}
|
||||
let base = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Outline"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[40,30]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["l1"],"value":"width"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["l2"],"value":"depth"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":"height","operation":"new","body_name":"Case"}));
|
||||
run(&mut s, "solid.fillet", json!({"body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6}));
|
||||
run(&mut s, "solid.shell", json!({"body":"Case","faces":[[0,0,30]],"thickness":"wall","name":"Hollow"}));
|
||||
assert_eq!(s.model.state().body("Case").unwrap().body.face_count(), 19, "rounded Shell has no separate base strips");
|
||||
let pick = |s: &Session, normal: Vec3, point: Vec3| {
|
||||
let state = s.model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let face = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|f| f.plane_normal.is_some_and(|n| n.dot(normal) > 0.999) && (f.centroid - point).dot(normal).abs() < 1e-6)
|
||||
.unwrap();
|
||||
json!({"body":"Case","index":face.index,"face":[point.x,point.y,point.z]})
|
||||
};
|
||||
let side_pick = pick(&s, Vec3::X, Vec3::new(40., 10., 20.));
|
||||
let side = run(&mut s, "sketch.create", json!({"name":"USB","plane":side_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
|
||||
let port_plane = s.model.state().sketch(side).unwrap().plane;
|
||||
let p0 = port_plane.to_local(Vec3::new(40., -6., 8.5));
|
||||
let p1 = port_plane.to_local(Vec3::new(40., 6., 15.5));
|
||||
let port_curves = run(&mut s, "sketch.rectangle.two_point", json!({"p0":[p0.x,p0.y],"p1":[p1.x,p1.y]}))["curves"].clone();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":side,"profiles":[{"curves":port_curves}],"distance":"wall","direction":"negative","operation":"cut","targets":["Case"],"name":"USBcut"}),
|
||||
);
|
||||
let floor_pick = pick(&s, Vec3::Z, Vec3::new(6., -9., 2.));
|
||||
let floor = run(&mut s, "sketch.create", json!({"name":"Bosses","plane":floor_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
|
||||
let boss_curves = run(&mut s, "sketch.circle.center", json!({"center":[30,20],"radius":3.5}))["curves"].clone();
|
||||
let centre = format!("{}.center", boss_curves[0].as_str().unwrap());
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"horizontal","value":"width / 2 - 10 mm"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"vertical","value":"depth / 2 - 10 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":floor,"profiles":[{"curves":boss_curves}],"distance":12,"operation":"join","targets":["Case"],"name":"Boss"}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.pattern.rectangular",
|
||||
json!({"features":["Boss"],"dir1":[-1,0,0],"count1":2,"spacing1":"width - 20 mm","dir2":[0,-1,0],"count2":2,"spacing2":"depth - 20 mm","name":"BossPattern"}),
|
||||
);
|
||||
for id in [side, floor] {
|
||||
let FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!() };
|
||||
assert!(matches!(plane_face_anchor(plane).unwrap().target.certificate, Some(FaceCertificate::ShellPlanar { .. })));
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name":"width","expression":"100 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
for session in [&s, &reopened] {
|
||||
for id in [side, floor] {
|
||||
let result = session.model.result(id).unwrap();
|
||||
assert!(result.error.is_none(), "{}: {:?}", result.name, result.error);
|
||||
assert!(result.warning.is_none(), "{}: {:?}", result.name, result.warning);
|
||||
assert!(
|
||||
session.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost),
|
||||
"projected boundary must resolve current geometry"
|
||||
);
|
||||
}
|
||||
let state = session.model.state();
|
||||
assert!(state.body("Case").is_some());
|
||||
assert!((state.sketch(side).unwrap().plane.origin.x - 50.).abs() < 1e-6);
|
||||
assert!((state.sketch(floor).unwrap().plane.origin.z - 3.).abs() < 1e-6);
|
||||
assert!(state.sketch(floor).unwrap().plane.normal().dot(Vec3::Z) > 0.999);
|
||||
let mesh = state.body("Case").unwrap().body.tessellate_with(1e-4, true).unwrap();
|
||||
let case = &state.body("Case").unwrap().body;
|
||||
let corners: Vec<_> = (0..case.face_count()).filter(|i| matches!(solvecraft_kernel::face_surface(case, *i), Some(solvecraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 6.).abs() < 1e-6)).collect();
|
||||
assert_eq!(corners.len(), 4, "later port/boss features must not subdivide untouched outer corners");
|
||||
for index in corners {
|
||||
let points: Vec<_> = mesh
|
||||
.edge_faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, faces)| faces.contains(&(index as u32)))
|
||||
.flat_map(|(edge, _)| &mesh.edges[edge])
|
||||
.collect();
|
||||
assert!(points.iter().any(|p| p.z.abs() < 1e-8));
|
||||
assert!(points.iter().any(|p| (p.z - 30.).abs() < 1e-8));
|
||||
}
|
||||
|
||||
assert!(!mesh.contains(Vec3::new(49., 0., 12.)), "USB must remain open after resize/reopen");
|
||||
assert!(mesh.contains(Vec3::new(49., 15., 12.)), "adjacent right wall remains solid");
|
||||
assert!(mesh.contains(Vec3::new(-49., 0., 12.)), "opposite wall must stay solid");
|
||||
let seed = state.sketch(floor).unwrap().plane.to_world(solvecraft_geom::Vec2::new(40., 20.)) + Vec3::Z * 6.;
|
||||
assert!(mesh.contains(seed), "seed boss starts on current floor");
|
||||
for shift in [Vec3::ZERO, Vec3::new(-80., 0., 0.), Vec3::new(0., -40., 0.), Vec3::new(-80., -40., 0.)] {
|
||||
assert!(mesh.contains(seed + shift), "each patterned boss must survive resize/reopen at {:?}", seed + shift);
|
||||
}
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 6.)), "the cavity remains hollow");
|
||||
let area = |w: f64, d: f64, r: f64| w * d - (4. - std::f64::consts::PI) * r * r;
|
||||
let expected = area(100., 60., 6.) * 30. - area(94., 54., 3.) * 27. - 12. * 7. * 3. + 4. * std::f64::consts::PI * 3.5 * 3.5 * 12.;
|
||||
assert!((mesh.measure().volume - expected).abs() < 0.1, "actual {}, analytic {expected}", mesh.measure().volume);
|
||||
}
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Hollow","suppressed":true}));
|
||||
assert!(s.model.result(side).unwrap().error.is_some());
|
||||
assert!(s.model.result(floor).unwrap().error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_rectangle_pocket_has_one_logical_outer_wall_and_closed_face_projection() {
|
||||
let mut s = Session::default();
|
||||
let base = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[30,27]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":10,"operation":"new","body_name":"Stock"}));
|
||||
let control_sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[100,100],"p1":[104,104]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":control_sketch,"distance":10,"operation":"new","body_name":"Control"}));
|
||||
let untouched = s.model.state().body("Control").unwrap().body.clone();
|
||||
let before_control = solvecraft_kernel::step_export(&[&untouched], "control snapshot").unwrap();
|
||||
let pick = top_pick(&s, "Stock");
|
||||
let pocket = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
let side = {
|
||||
let state = s.model.state();
|
||||
let sk = &state.sketch(pocket).unwrap().sketch;
|
||||
let curve = sk.curves.iter().find(|c| c.id == "l1").unwrap();
|
||||
let solvecraft_sketch::CurveKind::Line { a, b } = curve.kind else { panic!() };
|
||||
sk.point(a).unwrap() + sk.point(b).unwrap()
|
||||
};
|
||||
let outer = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":0.3425231845240546,"side":[side.x,side.y]}))["curves"].clone();
|
||||
let inner = run(&mut s, "sketch.offset", json!({"curves":[outer[0]],"distance":5.609348914858092,"side":[0,0]}))["curves"].clone();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":pocket,"profiles":[{"curves":inner}],"distance":-9,"operation":"cut"}));
|
||||
let control = s.model.state().body("Control").unwrap().body.clone();
|
||||
assert!(control.same(&untouched), "a separate body crossing the cutter end plane remains the exact same solid");
|
||||
assert_eq!(solvecraft_kernel::step_export(&[&control], "control snapshot").unwrap(), before_control);
|
||||
let state = s.model.state();
|
||||
let body = state.body("Stock").unwrap();
|
||||
let display = body.body.display_mesh(0.01).unwrap();
|
||||
let face = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.999) && (face.centroid.x - 30.).abs() < 1e-6)
|
||||
.unwrap();
|
||||
assert_eq!(body.body.faces(0.01).unwrap().len(), 11, "actual pocket topology has no outer floor-height strips");
|
||||
assert_eq!(display.surface_patch(face.index).len(), 1);
|
||||
let attached = run(&mut s, "sketch.create", json!({"plane":{"body":"Stock","index":face.index,"face":[30,0,0.5]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
for session in [&s, &reopened] {
|
||||
let result = session.model.result(attached).unwrap();
|
||||
assert!(result.error.is_none(), "{:?}", result.error);
|
||||
assert!(result.warning.is_none(), "{:?}", result.warning);
|
||||
let state = session.model.state();
|
||||
let sketch = &state.sketch(attached).unwrap().sketch;
|
||||
assert!(sketch.links.iter().all(|link| !link.lost));
|
||||
assert_eq!(sketch.curves.len(), 4, "whole outer-wall rectangle has no floor-height stations");
|
||||
let mesh = state.body("Stock").unwrap().body.display_mesh(0.01).unwrap();
|
||||
assert!(mesh.contains(Vec3::new(0., 0., 0.5)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 5.)));
|
||||
assert!(mesh.contains(Vec3::new(28., 0., 5.)));
|
||||
let control = &state.body("Control").unwrap().body;
|
||||
assert_eq!(control.faces(0.01).unwrap().len(), 6);
|
||||
assert_eq!(control.edges(0.01).unwrap().len(), 12);
|
||||
let control_mesh = control.tessellate(0.01).unwrap();
|
||||
assert!((control_mesh.measure().volume - 160.).abs() < 1e-6);
|
||||
assert!(control_mesh.contains(Vec3::new(102., 102., 0.5)));
|
||||
assert!(control_mesh.contains(Vec3::new(102., 102., 5.)));
|
||||
let expected = 60. * 54. * 10. - 4. * 24.73317426966596 * 21.733174269665962 * 9.;
|
||||
assert!((mesh.measure().volume - expected).abs() < 1e-6, "actual {}, expected {expected}", mesh.measure().volume);
|
||||
}
|
||||
}
|
||||
+39
-14
@@ -23,28 +23,53 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
#[test]
|
||||
fn join_extrude_from_a_chamfered_top_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"height","expression":"20 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name":"bevel","expression":"2 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance": "height","name":"Stem"}))["feature"].as_u64().unwrap();
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": "bevel"}));
|
||||
let base = volume(&mut s);
|
||||
let before = s.doc.to_json();
|
||||
let active = s.active_sketch;
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.active_sketch, active);
|
||||
assert!(rel(volume(&mut s), base) < 1e-12);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else {
|
||||
panic!("explicit fixed face must become a custom plane");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, sketch } =
|
||||
&s.doc.feature(id).unwrap().kind
|
||||
else {
|
||||
panic!("the chamfered cap retains its exact original construction support");
|
||||
};
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9);
|
||||
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections");
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8}));
|
||||
assert_eq!(anchor.target.origin_feature, source);
|
||||
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::ExtrudeCap { high: true, .. })));
|
||||
assert!(!sketch.links.is_empty());
|
||||
assert!(sketch.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
// The default linked cap outline is radius8; no explicit fixed plane or
|
||||
// duplicate manually drawn circle is required to join a pad to that face.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
|
||||
for (height, bevel) in [(20., 2.), (25., 3.)] {
|
||||
run(&mut s, "parameters.change", json!({"name":"height","expression":format!("{height} mm")}));
|
||||
run(&mut s, "parameters.change", json!({"name":"bevel","expression":format!("{bevel} mm")}));
|
||||
let expected = PI * (100. * height - (10. * bevel * bevel - bevel * bevel * bevel / 3.) + (10. - bevel) * (10. - bevel) * 5.);
|
||||
let loaded = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut cold = Session::new(loaded);
|
||||
let warm_volume = volume(&mut s);
|
||||
let cold_volume = volume(&mut cold);
|
||||
assert!(rel(warm_volume, expected) < 5e-4, "{warm_volume} vs {expected}");
|
||||
assert!((warm_volume - cold_volume).abs() < 1e-8);
|
||||
for current in [&s, &cold] {
|
||||
assert!(current.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", current.model.results);
|
||||
let state = current.model.state();
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let solved = state.sketch(id).unwrap();
|
||||
assert!((solved.plane.origin.z - height).abs() < 1e-8);
|
||||
assert!(solved.sketch.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
let body = &state.bodies[0];
|
||||
assert!(body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 4.)));
|
||||
assert!(!body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 6.)));
|
||||
assert!(body.body.contains(solvecraft_geom::Vec3::new(10. - bevel - 0.5, 0., height + 4.)));
|
||||
assert!(!body.body.contains(solvecraft_geom::Vec3::new(10. - bevel + 0.5, 0., height + 4.)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
|
||||
|
||||
@@ -5,6 +5,10 @@ use truck_modeling as mt;
|
||||
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[path = "planar_regularize.rs"]
|
||||
mod planar_regularize;
|
||||
#[path = "planar_sources.rs"]
|
||||
mod planar_sources;
|
||||
#[path = "shell_groups.rs"]
|
||||
mod shell_groups;
|
||||
|
||||
@@ -33,11 +37,12 @@ pub struct Body {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane>, coalesced: bool },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
|
||||
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
|
||||
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
|
||||
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
|
||||
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solvecraft_geom::Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
@@ -48,7 +53,9 @@ impl FaceSource {
|
||||
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
|
||||
match self {
|
||||
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
|
||||
Some(plane)
|
||||
}
|
||||
Self::Imported { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -358,6 +365,12 @@ impl Body {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// A current wall reconstructed from an exact contiguous section run.
|
||||
/// This transient witness grants no durable source/producer identity.
|
||||
pub fn face_was_coalesced(&self, index: usize) -> bool {
|
||||
matches!(self.face_sources().and_then(|s| s.get(index)), Some(Some(FaceSource::ExtrudeSide { coalesced: true, .. })))
|
||||
}
|
||||
|
||||
/// An exact construction/import plane; never inferred from tessellation.
|
||||
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
|
||||
if index >= 50_000 {
|
||||
@@ -366,7 +379,10 @@ impl Body {
|
||||
let source = self.face_sources()?.get(index)?.as_ref()?;
|
||||
match source {
|
||||
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
|
||||
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
|
||||
FaceSource::ExtrudeCap { plane, .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane, .. }
|
||||
| FaceSource::Box { plane, .. }
|
||||
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
|
||||
FaceSource::Imported { .. } => self.planar_face(index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -116,18 +116,32 @@ fn construction_planes_follow_reflected_and_sheared_affine_images() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_changes_discard_producer_certificates() {
|
||||
fn material_changes_preserve_only_unchanged_planar_support_certificates() {
|
||||
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
|
||||
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
|
||||
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
|
||||
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
|
||||
source,
|
||||
FaceSource::ExtrudeSide { .. }
|
||||
| FaceSource::ExtrudeCap { .. }
|
||||
| FaceSource::Box { producer: None, .. }
|
||||
| FaceSource::Imported { producer: None, .. }
|
||||
)));
|
||||
// Unchanged uniquely certified supports retain their role; a newly cut
|
||||
// support must never inherit the removed opposite wall's source identity.
|
||||
let mut retained = 0;
|
||||
let mut new_wall = 0;
|
||||
for index in 0..cut.face_count() {
|
||||
let actual = cut.planar_face(index).unwrap();
|
||||
let source = cut.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
|
||||
if let Some(FaceSource::Box { producer: Some(42), axis, high, .. }) = source {
|
||||
assert!(!(*axis == 0 && *high));
|
||||
let canonical = cut.face_plane(index).unwrap();
|
||||
assert!(actual.normal().dot(canonical.normal()) > 1. - 1e-10);
|
||||
assert!(actual.height(canonical.origin).abs() < 1e-10);
|
||||
retained += 1;
|
||||
}
|
||||
if actual.normal().x > 0.9 {
|
||||
assert!((actual.origin.x - 5.).abs() < 1e-10);
|
||||
assert!(!matches!(source, Some(FaceSource::Box { producer: Some(_), .. })));
|
||||
new_wall += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!((retained, new_wall), (5, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(®ion), 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
|
||||
|
||||
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
|
||||
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
|
||||
}
|
||||
|
||||
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
b.require_brep("shell")?;
|
||||
if !(thickness.is_finite() && thickness > 1e-6) {
|
||||
return Err(KernelError::Invalid("shell thickness must be positive".into()));
|
||||
|
||||
+153
-23
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
|
||||
};
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
sew_sections(plane, d, &levels, &slabs, tol)
|
||||
sew_sections(plane, d, &levels, &slabs, tol, false)
|
||||
}
|
||||
|
||||
/// Sew the exact sections between successive levels, including empty gaps.
|
||||
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
|
||||
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
|
||||
if levels.len() != slabs.len() + 1
|
||||
|| slabs.len() > 128
|
||||
|| plane.normal().dot(d) < 1. - 1e-12
|
||||
|| levels.iter().any(|z| !z.is_finite())
|
||||
|| levels.windows(2).any(|z| z[0] >= z[1])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
|
||||
pieces.push(piece);
|
||||
}
|
||||
} else {
|
||||
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
|
||||
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
|
||||
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
|
||||
remaining_faces =
|
||||
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
|
||||
@@ -765,8 +770,18 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
|
||||
})())
|
||||
}
|
||||
|
||||
/// Stable exact identity for the bounded analytic segment subset.
|
||||
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
|
||||
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
|
||||
match s {
|
||||
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
|
||||
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A genuinely stepped connected axial block has no single extrusion footprint.
|
||||
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
|
||||
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
||||
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
|
||||
@@ -800,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) },
|
||||
}
|
||||
};
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
for (i, s) in slabs.iter().enumerate() {
|
||||
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
|
||||
for lp in loops_of(s) {
|
||||
// Exact oriented generated segments alone define a run. No support fitting,
|
||||
// proximity matching, or old face lineage is used here.
|
||||
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
|
||||
let mut last = std::collections::BTreeMap::new();
|
||||
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
|
||||
for (i, slab) in slabs.iter().enumerate() {
|
||||
for lp in loops_of(slab) {
|
||||
for sg0 in &lp.segs {
|
||||
for sg in split_seg(sg0, &pts, tol)? {
|
||||
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 lp3 = vec![
|
||||
edge(&sg, z0),
|
||||
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
|
||||
edge(&sg.reversed(), z1),
|
||||
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
|
||||
];
|
||||
let tangent = plane.dir_to_world(sg.derivative(0.5));
|
||||
let outward = tangent.cross(d).normalized()?;
|
||||
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
|
||||
lp3.push(edge(&sg, z0));
|
||||
// Keep station vertices at both ends: neighboring stepped walls/caps
|
||||
// must share complete edges rather than create T-junctions.
|
||||
for k in first..end {
|
||||
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
|
||||
}
|
||||
lp3.push(edge(&sg.reversed(), z1));
|
||||
for k in (first..end).rev() {
|
||||
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
|
||||
}
|
||||
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
|
||||
let surface = match sg {
|
||||
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
@@ -836,9 +882,10 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
||||
}
|
||||
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
||||
sources.push(None);
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew_touching(&faces))
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
|
||||
}
|
||||
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1055,7 +1102,19 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
|
||||
if slabs.iter().all(Vec::is_empty) {
|
||||
return Some(Ok(None));
|
||||
}
|
||||
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
|
||||
sew_sections(
|
||||
&plane,
|
||||
d,
|
||||
&levels,
|
||||
&slabs,
|
||||
tol,
|
||||
op == BoolOp::Cut
|
||||
&& a.split_keep().is_empty()
|
||||
&& b.split_keep().is_empty()
|
||||
&& a.paint().is_none_or(|p| p.faces.is_empty())
|
||||
&& b.paint().is_none_or(|p| p.faces.is_empty()),
|
||||
)
|
||||
.map(|r| r.map(Some))
|
||||
}
|
||||
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1144,7 +1203,12 @@ mod planar_tests {
|
||||
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
|
||||
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
|
||||
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
|
||||
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
|
||||
assert!(
|
||||
lump.face_sources().is_none_or(|s| s
|
||||
.iter()
|
||||
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
|
||||
"genuinely stepped geometry has no durable certificate"
|
||||
);
|
||||
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1216,72 @@ mod planar_tests {
|
||||
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
|
||||
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
|
||||
}
|
||||
#[test]
|
||||
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
|
||||
for curved in [false, true] {
|
||||
let outer = if curved {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
|
||||
} else {
|
||||
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
|
||||
};
|
||||
let mut upper = outer.clone();
|
||||
upper.holes.push(if curved {
|
||||
Loop2::circle(Vec2::ZERO, 5.).reversed()
|
||||
} else {
|
||||
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
|
||||
});
|
||||
let slabs = vec![vec![outer], vec![upper]];
|
||||
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false).unwrap().unwrap();
|
||||
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true).unwrap().unwrap();
|
||||
let count = if curved { 2 } else { 4 };
|
||||
assert_eq!(old.face_count() - merged.face_count(), count);
|
||||
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
|
||||
assert_eq!(marked.len(), count);
|
||||
for (_, face) in marked {
|
||||
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
|
||||
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
|
||||
for z in [0., 2., 20.] {
|
||||
assert!(heights.contains(&z));
|
||||
}
|
||||
}
|
||||
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
|
||||
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
|
||||
let mesh = merged.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
||||
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
||||
assert!(merged.validity().is_empty());
|
||||
assert_ne!(wall_key(&slabs[0][0].outer.segs[0]), wall_key(&slabs[0][0].outer.segs[0].reversed()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
|
||||
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
|
||||
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
|
||||
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
|
||||
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||
let protected = [
|
||||
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
|
||||
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
|
||||
];
|
||||
for input in protected {
|
||||
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||
assert_eq!(output.face_count(), merged.face_count() + 2);
|
||||
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
|
||||
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
|
||||
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
|
||||
for body in [&output, &merged] {
|
||||
let mesh = body.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
||||
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
||||
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
|
||||
let plane = Plane::XY;
|
||||
|
||||
@@ -97,13 +97,16 @@ impl Cache {
|
||||
|
||||
/// Sew the faces into a closed solid.
|
||||
pub fn sew(faces: &[FaceSpec]) -> Result<Body> {
|
||||
sew_impl(faces, false)
|
||||
sew_impl(faces, false, None)
|
||||
}
|
||||
|
||||
/// Separate boundary sheets at a shared geometric edge without moving it.
|
||||
/// The face adjacency must still describe one connected solid boundary.
|
||||
pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result<Body> {
|
||||
sew_impl(faces, true)
|
||||
/// Optional transient sources follow only unchanged produced face identities.
|
||||
pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option<crate::FaceSource>]) -> Result<Body> {
|
||||
if sources.len() != faces.len() {
|
||||
return Err(KernelError::Invalid("sew source alignment".into()));
|
||||
}
|
||||
sew_impl(faces, true, Some(sources))
|
||||
}
|
||||
|
||||
type EdgeUse = (usize, usize, usize);
|
||||
@@ -347,7 +350,7 @@ fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::FaceSource>]>) -> Result<Body> {
|
||||
if faces.len() < 4 || faces.len() > 200_000 {
|
||||
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
|
||||
}
|
||||
@@ -396,6 +399,11 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
};
|
||||
out.push(face);
|
||||
}
|
||||
let source_ids: HashMap<_, _> = out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| sources.and_then(|s| s.get(i)).and_then(Option::as_ref).map(|s| (f.id(), (f.orientation(), s))))
|
||||
.collect();
|
||||
if !sheets.is_empty() && !faces_connected(&out) {
|
||||
return Err(KernelError::Invalid("sew: bodies touch only along an edge".into()));
|
||||
}
|
||||
@@ -404,7 +412,10 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
|
||||
}
|
||||
let shells = closed_components(out)?;
|
||||
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
|
||||
Body::new(solid)
|
||||
let body = Body::new(solid)?;
|
||||
let mapped =
|
||||
body.solid.face_iter().map(|f| source_ids.get(&f.id()).filter(|(o, _)| *o == f.orientation()).map(|(_, s)| (*s).clone())).collect();
|
||||
Ok(body.with_face_sources(sources.map(|_| std::sync::Arc::new(mapped))))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1273,7 +1273,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
update_fill_cells(app, &mut d);
|
||||
let t = Tokens::get();
|
||||
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 ok = false;
|
||||
let mut applied = false;
|
||||
@@ -1303,7 +1304,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
width,
|
||||
)
|
||||
.frame(frame)
|
||||
.scroll([false, true])
|
||||
.open(&mut keep)
|
||||
.show(ctx, |ui| {
|
||||
ui.spacing_mut().text_edit_width = FIELD_W;
|
||||
@@ -1332,6 +1332,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
// 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 {
|
||||
@@ -1923,6 +1930,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Footer: an info glyph at the left, OK and Cancel at the right.
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -1023,6 +1023,29 @@ mod tests {
|
||||
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
|
||||
}
|
||||
|
||||
fn click_rule_button(h: &mut crate::scenario::Harness, name: &str) {
|
||||
// The table header also contains this name. Its TextRun may be emitted
|
||||
// after the popup, so select the actual choice rather than matching text.
|
||||
let bounds = h
|
||||
.output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(_, n)| {
|
||||
(n.role() == egui::accesskit::Role::Button && !n.is_disabled() && n.label() == Some(name)).then(|| n.bounds()).flatten()
|
||||
})
|
||||
.expect("rule button");
|
||||
h.step(&json!({"click": [(bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0]})).unwrap();
|
||||
}
|
||||
|
||||
fn select_rule_parameter(h: &mut crate::scenario::Harness, name: &str) {
|
||||
click_rule_button(h, name);
|
||||
assert!(h.widget("Choose…").is_none(), "parameter choice should close and update the dropdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -1032,9 +1055,11 @@ mod tests {
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},{"widget":"Add or edit rule"},
|
||||
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
|
||||
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"}
|
||||
{"widget":"Choose…"}
|
||||
]))
|
||||
.unwrap();
|
||||
select_rule_parameter(&mut h, "length");
|
||||
h.step(&json!({"widget":"Save Rule"})).unwrap();
|
||||
assert!(
|
||||
matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..}))
|
||||
);
|
||||
@@ -1044,7 +1069,8 @@ mod tests {
|
||||
])).unwrap();
|
||||
assert!(h.widget("length = 20 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap();
|
||||
click_rule_button(&mut h, "↑");
|
||||
h.step(&json!({"widget":"Preview Row"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.configs.rules[0].id, "last");
|
||||
assert!(h.widget("length = 10 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
@@ -1060,8 +1086,11 @@ mod tests {
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},
|
||||
{"widget":"Add or edit rule"},
|
||||
{"widget":"Choose…"},
|
||||
{"widget":"length"},
|
||||
{"widget":"Choose…"}
|
||||
]))
|
||||
.unwrap();
|
||||
select_rule_parameter(&mut h, "length");
|
||||
h.run(&json!([
|
||||
{"widget":"Save Rule"}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
@@ -89,6 +89,51 @@ pub fn right_aligned<R>(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn feature_forms_use_available_height_and_keep_actions_visible() {
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 900.0, 2.0)] {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.ui_scale = scale;
|
||||
h.call("ui.resize", json!({"width":width / scale,"height":height / scale}));
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.step(&json!({"start":"solid.extrude"})).unwrap();
|
||||
h.frames(10);
|
||||
let dialog = h.app.viewport.dialog_rect.unwrap();
|
||||
let viewport = h.app.viewport.rect.unwrap();
|
||||
assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}");
|
||||
assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}");
|
||||
assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling");
|
||||
if scale == 1.0 {
|
||||
assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling");
|
||||
}
|
||||
assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
|
||||
assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
h.step(&json!({"start":"solid.shell"})).unwrap();
|
||||
h.frames(8);
|
||||
let short = h.app.viewport.dialog_rect.unwrap();
|
||||
assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}");
|
||||
assert!(h.widget("Cancel").is_some());
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
h.step(&json!({"start":"solid.hole"})).unwrap();
|
||||
if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() {
|
||||
*kind = 1;
|
||||
opts.tapped = true;
|
||||
}
|
||||
h.frames(8);
|
||||
let long = h.app.viewport.dialog_rect.unwrap();
|
||||
assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}");
|
||||
assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible");
|
||||
assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
}
|
||||
}
|
||||
/// Windows are made in this file only, so none can skip the width limits.
|
||||
#[test]
|
||||
fn windows_come_from_the_frame_helper() {
|
||||
|
||||
@@ -78,6 +78,8 @@ mod revolve_edit_tests;
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
#[cfg(test)]
|
||||
mod shell_face_sketch_ui_tests;
|
||||
pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{
|
||||
Sel,
|
||||
doc::{FaceAttachmentStatus, FeatureKind, PlaneRef},
|
||||
};
|
||||
|
||||
fn shell() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Case"})).unwrap();
|
||||
h.app.run("solid.shell", json!({"faces":[[20,15,20]],"thickness":2})).unwrap();
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn assert_attached(h: &Harness) {
|
||||
let id = h.app.session.active_sketch.expect("a combined certified wall remains a sketch target");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(id).unwrap().kind else {
|
||||
panic!("wall sketch must have a durable attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "Case");
|
||||
assert_eq!(serde_json::to_value(&anchor.target.certificate).unwrap()["kind"], "shell_planar");
|
||||
assert!(matches!(h.app.session.doc.sketch_face_attachment(id, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
|
||||
let world = h.app.session.world_state();
|
||||
let solved = world.sketch(id).unwrap();
|
||||
assert!(solved.plane.origin.y.abs() < 1e-8);
|
||||
assert!(solved.plane.normal().y.abs() > 0.999);
|
||||
assert!(h.app.session.model.result(id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
fn selected_wall(h: &Harness) -> usize {
|
||||
assert_eq!(h.app.session.selection.len(), 1, "the exterior wall is one physical face, not a grouped lower strip");
|
||||
let Sel::Face { body, index, .. } = &h.app.session.selection[0] else {
|
||||
panic!("actual wall click must select a face");
|
||||
};
|
||||
assert_eq!(body, "Case");
|
||||
*index
|
||||
}
|
||||
|
||||
fn assert_one_physical_wall(h: &mut Harness) -> usize {
|
||||
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
|
||||
let lower = selected_wall(h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
h.step(&json!({"click":{"world":[20,0,10]}})).unwrap();
|
||||
assert_eq!(selected_wall(h), lower, "below and above the floor height must hit the same BRep face index");
|
||||
lower
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_shell_wall_patch_still_creates_a_certified_sketch() {
|
||||
let mut h = shell();
|
||||
assert_one_physical_wall(&mut h);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.settle();
|
||||
assert_attached(&h);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_create_sketch_accepts_an_actual_shell_wall_patch_click() {
|
||||
let mut h = shell();
|
||||
assert_one_physical_wall(&mut h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
|
||||
h.settle();
|
||||
assert_attached(&h);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_shell_corner_is_one_physical_face_above_and_below_the_floor() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":80,"width":60,"height":30,"corner":[-40,-30,0],"body_name":"Case"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6})).unwrap();
|
||||
h.app.run("solid.shell", json!({"faces":[[0,0,30]],"thickness":2})).unwrap();
|
||||
h.call("ui.view", json!({"view":"iso"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
let state = h.app.session.world_state();
|
||||
assert_eq!(state.body("Case").unwrap().body.face_count(), 19, "no artificial lower corner faces remain in the BRep");
|
||||
let document = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,1]}})).unwrap();
|
||||
let lower = selected_wall(&h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,15]}})).unwrap();
|
||||
assert_eq!(selected_wall(&h), lower, "lower and upper corner clicks hit the same actual face, not two grouped pieces");
|
||||
assert_eq!(h.app.session.doc.to_json(), document);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
}
|
||||
@@ -142,14 +142,16 @@ fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
|
||||
// An intentional split gives the +z source two distinct planar pieces. A
|
||||
// strict attachment cannot claim the original whole-face certificate.
|
||||
h.app.run("solid.split_face", json!({"body":"Modified","faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[2.5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
|
||||
@@ -42,16 +42,14 @@ impl Layout {
|
||||
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
|
||||
let available = free_rect(viewport, &obstacles);
|
||||
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
|
||||
// Navigation is anchored to the viewport, never to transient dialog space.
|
||||
let available = viewport;
|
||||
let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
|
||||
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
|
||||
} else {
|
||||
Rect::NOTHING
|
||||
};
|
||||
if axis_view.is_positive() {
|
||||
obstacles.push(axis_view);
|
||||
}
|
||||
let nav_space = free_rect(viewport, &obstacles);
|
||||
let nav_space = viewport;
|
||||
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
|
||||
let nav_height = (10.0 / columns).ceil() * 30.0;
|
||||
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
|
||||
@@ -59,6 +57,14 @@ impl Layout {
|
||||
} else {
|
||||
Rect::NOTHING
|
||||
};
|
||||
// On tiny viewports keep the bottom controls usable rather than overlapping them.
|
||||
// This decision depends only on viewport size, never on an overlay.
|
||||
if axis_view.intersect(navigation).is_positive() {
|
||||
axis_view = Rect::NOTHING;
|
||||
}
|
||||
if axis_view.is_positive() {
|
||||
obstacles.push(axis_view);
|
||||
}
|
||||
if navigation.is_positive() {
|
||||
obstacles.push(navigation);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,19 @@ fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
|
||||
assert_eq!(outer, layout.axis_view);
|
||||
assert!(outer.contains_rect(home));
|
||||
assert!(layout.viewport.contains_rect(outer));
|
||||
assert!(!outer.intersect(palette).is_positive());
|
||||
assert_eq!(outer.right_top(), layout.viewport.right_top());
|
||||
if outer.intersect(palette).is_positive() {
|
||||
// Short palettes may cover the fixed anchor; covered controls never steal input.
|
||||
let covered = outer.intersect(palette).center();
|
||||
let camera = h.app.cam;
|
||||
h.call("ui.click", json!({"x":covered.x,"y":covered.y}));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer);
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
continue;
|
||||
}
|
||||
assert!(!layout.navigation.intersect(outer).is_positive());
|
||||
for name in ["x", "-x", "y", "-y", "z", "-z"] {
|
||||
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap();
|
||||
@@ -194,3 +206,79 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() {
|
||||
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] {
|
||||
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
|
||||
let baseline = Layout::new(vp, &[], scale);
|
||||
assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive());
|
||||
assert_eq!(baseline.axis_view.right_top(), vp.right_top());
|
||||
assert_eq!(baseline.navigation.center().x, vp.center().x);
|
||||
assert_eq!(baseline.navigation.bottom(), vp.bottom() - 7.0);
|
||||
let cases = [
|
||||
vec![Rect::from_min_max(pos2(width - 220.0, vp.top() + 120.0), vp.max)],
|
||||
vec![Rect::from_min_size(vp.min, vec2(210.0, 170.0))],
|
||||
vec![Rect::from_center_size(vp.center(), vec2(250.0, 180.0)), Rect::from_min_size(vp.min, vec2(70.0, 120.0))],
|
||||
vec![vp],
|
||||
];
|
||||
for docks in cases {
|
||||
let measured = Layout::new(vp, &docks, scale);
|
||||
assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}");
|
||||
assert_eq!(measured.navigation, baseline.navigation, "{docks:?}");
|
||||
assert!(vp.contains_rect(measured.axis_view));
|
||||
assert!(vp.contains_rect(measured.navigation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_different_command_dialogs_keeps_published_navigation_positions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
h.settle();
|
||||
let axis = crate::scenario::rect_of("axis_view:outer").unwrap();
|
||||
let nav = crate::scenario::rect_of("nav:outer").unwrap();
|
||||
let camera = h.app.cam;
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
for command in ["solid.extrude", "solid.fillet", "inspect.measure"] {
|
||||
h.app.start(command);
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_some(), "{command}");
|
||||
assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured");
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}");
|
||||
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}");
|
||||
assert_eq!(h.app.cam, camera);
|
||||
let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0);
|
||||
assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan));
|
||||
h.app.viewport.nav = None;
|
||||
h.call("ui.click", json!({"x":pan.x,"y":pan.y}));
|
||||
assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}");
|
||||
h.app.dialog = None;
|
||||
h.settle();
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis);
|
||||
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav);
|
||||
}
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_viewports_never_overlap_navigation_and_axis_view() {
|
||||
for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] {
|
||||
for height in [30.0, 80.0, 130.0, 160.0, 240.0] {
|
||||
let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height));
|
||||
let clear = Layout::new(vp, &[], 1.0);
|
||||
let covered = Layout::new(vp, &[vp], 1.0);
|
||||
assert_eq!(clear.axis_view, covered.axis_view);
|
||||
assert_eq!(clear.navigation, covered.navigation);
|
||||
assert!(!clear.axis_view.intersect(clear.navigation).is_positive());
|
||||
for control in [clear.axis_view, clear.navigation] {
|
||||
if control.is_positive() {
|
||||
assert!(vp.contains_rect(control), "{clear:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,20 +61,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice."
|
||||
"note": "The certified Shell floor is picked rather than the origin plane and starts a healthy attached sketch directly."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": false,
|
||||
"dialog": "this face has no exact planar source"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "Use fixed plane"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": true
|
||||
"sketching": true,
|
||||
"dialog": null,
|
||||
"errors": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user