feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+30
View File
@@ -71,6 +71,15 @@ pub enum PlaneRef {
},
}
/// Complete source curves of an outer boundary and each of its holes. Unlike a
/// span multiset, this identity is independent of adaptive spline subdivision.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WholeProfileBoundary {
pub outer: Vec<String>,
pub holes: Vec<Vec<String>>,
}
/// Which closed profiles of a sketch a feature uses.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -82,6 +91,11 @@ pub enum ProfileSel {
/// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable
/// across parameter changes.
Curves { loops: Vec<Vec<String>> },
/// Complete current boundaries of topology-aware analytic offset associations.
/// The local association identity survives retirement and resurrection of arc roles.
OffsetBoundaries { associations: Vec<String> },
/// Exact whole source coverage, including holes; partial source curves do not match.
WholeBoundaries { profiles: Vec<WholeProfileBoundary> },
/// The profile containing each sketch point.
Points { points: Vec<Vec2> },
}
@@ -1315,6 +1329,22 @@ impl FeatureKind {
}
}
/// Profile selections and their source sketches without cloning feature data.
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
match self {
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Loft { sections, .. } => {
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, &section.profiles)).collect()
}
_ => Vec::new(),
}
}
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
match self {
FeatureKind::Shell { faces, .. }
+63 -17
View File
@@ -781,19 +781,15 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
}
v
}
ProfileSel::Curves { loops } => {
let mut v = Vec::new();
for l in loops {
let mut want: Vec<&String> = l.iter().collect();
want.sort();
let found = ps.iter().find(|p| {
let mut have: Vec<&String> = p.outer_curves.iter().collect();
have.sort();
have == want
});
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone());
}
v
ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
crate::profile_selection_indices(ss, sel)?
.into_iter()
.map(|index| {
ps.get(index)
.map(|profile| profile.region.clone())
.ok_or_else(|| DocError::Invalid("profile index changed during resolution".into()))
})
.collect::<Result<Vec<_>>>()?
}
ProfileSel::Points { points } => {
let mut v = Vec::new();
@@ -1577,8 +1573,51 @@ pub fn revolve_extent_with_convention(
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
}
/// The exact input frame and scalar interval of a current, untrimmed constructor.
struct ExtrudeConstructor {
plane: Plane,
interval: (f64, f64),
}
/// Construct planar caps locally before placing the completed tool.
fn centered_extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len2() - 1.0).abs() > 1e-8
|| (plane.y.len2() - 1.0).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
return Err(DocError::Invalid("extrude frame is not finite and orthonormal".into()));
}
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
// wire can tilt an otherwise exact planar cap through numeric cancellation.
let bodies = kernel::extrude(&Plane::XY, regions, start, end)?;
if *plane == Plane::XY {
return Ok(bodies);
}
let n = plane.normal();
let m = [
[plane.x.x, plane.x.y, plane.x.z, 0.0],
[plane.y.x, plane.y.y, plane.y.z, 0.0],
[n.x, n.y, n.z, 0.0],
[plane.origin.x, plane.origin.y, plane.origin.z, 1.0],
];
bodies.into_iter().map(|body| kernel::transform_matrix(&body, m).map_err(DocError::from)).collect()
}
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
feature_tools_with_constructor(doc, vals, f, st, &mut None)
}
fn feature_tools_with_constructor(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &ModelState,
constructor: &mut Option<ExtrudeConstructor>,
) -> Result<Vec<Body>> {
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
@@ -1657,9 +1696,10 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
}
}
let mut tools = Vec::new();
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
for r in &regions {
if *operation == Operation::Join {
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
let exact = centered_extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
if exact.iter().all(|tool| {
st.bodies
.iter()
@@ -1674,13 +1714,18 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
}
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
}
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off };
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
if *operation == Operation::NewBody && extent.taper.is_none() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
tools = tools
@@ -2732,10 +2777,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Loft { operation, targets, .. }
| FeatureKind::Coil { operation, targets, .. }
| FeatureKind::Sweep { operation, targets, .. } => {
let tools = feature_tools(doc, vals, f, st)?;
let mut constructor = None;
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor)?;
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let handled = more::sheet_cut(f, st, &tools, targets)?;
let handled = more::sheet_cut(f, st, &tools, targets, constructor.as_ref())?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
+183 -25
View File
@@ -539,34 +539,139 @@ fn poly_area(p: &[Vec2]) -> f64 {
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
}
/// Current unmodified extrusion caps, including placement, certify the tool's depth.
/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab.
fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> {
use kernel::FaceSource;
#[derive(PartialEq)]
enum Signature<'a> {
Raw(&'a Region2),
Certified(&'a [String], &'a [Vec<String>]),
}
let sources = body.face_sources()?;
if sources.len() != body.face_count() || sources.len() > 16_384 {
return None;
}
let n = source.normal();
let mut family = None;
let mut caps = [None, None];
let mut projection_roundoff: f64 = 0.0;
let mut footprint = None;
for (index, entry) in sources.iter().enumerate() {
let (group, cap) = match entry.as_ref()? {
FaceSource::ExtrudeSide { .. } => (None, None),
FaceSource::ExtrudeCap { high, region, plane } => (None, Some((*high, *plane, Signature::Raw(region)))),
FaceSource::CertifiedExtrudeSide { producer, sketch, .. } => (Some((*producer, *sketch)), None),
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane } => {
(Some((*producer, *sketch)), Some((*high, *plane, Signature::Certified(outer, holes))))
}
_ => return None,
};
match family {
None => family = Some(group),
Some(previous) if previous != group => return None,
_ => {}
}
if let Some((high, plane, signature)) = cap {
if high && let Signature::Raw(region) = &signature {
footprint = Some((plane, (*region).clone()));
}
let valid = |p: &Plane| {
p.origin.is_finite()
&& p.x.is_finite()
&& p.y.is_finite()
&& (p.x.len2() - 1.0).abs() <= 1e-10
&& (p.y.len2() - 1.0).abs() <= 1e-10
&& p.x.dot(p.y).abs() <= 1e-10
&& p.normal().cross(n).len() <= 1e-10
};
let current = body.planar_face(index)?;
if !valid(&plane) || !valid(&current) {
return None;
}
// Bound floating point projection separately from model geometry tolerance.
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
return None;
}
projection_roundoff = projection_roundoff.max(roundoff);
let distance = (plane.origin - source.origin).dot(n);
if !distance.is_finite() || caps[usize::from(high)].is_some() {
return None;
}
caps[usize::from(high)] = Some((distance, signature));
}
}
let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None };
(low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint))
}
/// Exact prismatic machining, retained in sheet material/depth metadata.
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
pub(super) fn sheet_cut(
f: &Feature,
st: &mut ModelState,
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() {
return Ok(Vec::new());
}
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
let eligible = |original: &crate::sheet::SheetBody| {
all || if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
}
};
if !st.sheets.iter().any(&eligible) {
return Ok(Vec::new());
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let n = ss.plane.normal();
let mut work = sheet::FlatWork::new();
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
if tools.len() > 1000 || faces > 16_384 {
return Err(DocError::Invalid("sheet machining tools exceed the bounded geometry budget".into()));
}
work.charge(faces.saturating_mul(5))?;
let mut span = (f64::MAX, f64::MIN);
for t in tools {
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
let d = (p - ss.plane.origin).dot(n);
span = (span.0.min(d), span.1.max(d));
let mut cap_roundoff: f64 = 0.0;
let mut current_footprints = Vec::new();
for tool in tools {
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
// Modified tools need a current geometric prism certificate. The generic
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
return Ok(Vec::new());
};
if span.0 != f64::MAX && ((span.0 - lo).abs() > cap_roundoff + roundoff || (span.1 - hi).abs() > cap_roundoff + roundoff) {
// Different profile tools cannot be represented as one filled depth interval.
return Ok(Vec::new());
}
span = (span.0.min(lo), span.1.max(hi));
cap_roundoff = cap_roundoff.max(roundoff);
if let Some(footprint) = footprint {
current_footprints.push(footprint);
}
}
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
// Only untrimmed Distance tools may use their exact authored scalar interval.
// The cap certificate still qualifies their current physical slab above.
let selected_edges =
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
(current_footprints.len() == tools.len()
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
let mut plans = Vec::new();
let mut work = sheet::FlatWork::new();
for original in &st.sheets {
let eligible = all
|| if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
};
if !eligible {
'sheets: for original in &st.sheets {
if !eligible(original) {
continue;
}
let Some(mb) = st.body(&original.body) else {
@@ -605,28 +710,81 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
continue;
}
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
let roundoff = (cap_roundoff
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
/ sign.abs();
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
&& let Some((lo, hi)) = nominal
&& (span.0 - lo).abs() <= roundoff
&& (span.1 - hi).abs() <= roundoff
{
// Equality of the two frame origins proves zero origin displacement;
// use authored distances, never an inferred endpoint coincidence.
let normal = crate::apply_vector(&w, Vec3::X).cross(crate::apply_vector(&w, Vec3::Y));
let exact_sign = if normal == n {
1.0
} else if normal == -n {
-1.0
} else {
sign
};
Some((lo / exact_sign, hi / exact_sign))
} else {
None
};
if nominal_depth.is_none() && roundoff > 1e-7 {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
continue 'sheets;
}
let (a, b) = nominal_depth.unwrap_or((a, b));
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
};
if *operation == Operation::Intersect {
if z1 <= z0 {
sh.holes.push(panel);
modeled = true;
continue;
}
if z0 > 1e-7 || z1 < sh.t - 1e-7 {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
continue;
}
let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?;
let tool = sheet::material_union(mapped, &mut work)?;
let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?;
let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?;
let removed = sheet::material_boolean(std::slice::from_ref(&panel), &retained, kernel::BoolOp::Cut, &mut work)?;
if removed.is_empty() && !sh.material_outside_depth(&panel, z0, z1, &mut work)? {
modeled = true;
continue;
}
if !retained.is_empty() && !depth_resolved {
return Err(DocError::Invalid(
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
));
}
sh.holes.extend(removed);
// Preserve original stock thickness and prior machining. Intersection
// removes the complementary layers above/below every surviving region.
for region in retained {
if z0 > 0.0 {
sh.record_pocket(region.clone(), 0.0, z0, &mut work)?;
}
if z1 < sh.t {
sh.record_pocket(region, z1, sh.t, &mut work)?;
}
}
modeled = true;
} else if z1 > z0 {
for r in &regions {
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
for flat in mapped {
let clipped =
sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?;
if !clipped.is_empty() && !depth_resolved {
return Err(DocError::Invalid(
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
));
}
if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 {
// Keep a single original tool boundary across structural seams.
// Re-clipping notch polygons creates coincident seam endpoints.
@@ -636,7 +794,7 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
modeled = true;
} else {
for region in clipped {
sh.pockets.push(sheet::SheetPocket { region, z0, z1 });
sh.record_pocket(region, z0, z1, &mut work)?;
modeled = true;
}
}
+8 -2
View File
@@ -33,13 +33,16 @@
//! their space; saved coordinates are never rewritten during migration.
//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve
//! conventions. Absent markers retain legacy attachment and interpolation behavior.
//! - 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.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 11;
pub const FORMAT: u32 = 12;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -52,7 +55,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; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 11] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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,
@@ -149,6 +153,7 @@ pub fn read(s: &str) -> Result<Document> {
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
@@ -202,6 +207,7 @@ pub fn read(s: &str) -> Result<Document> {
}
}
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
crate::format_offsets::validate_typed(&d)?;
normalise(&mut d)?;
d.format = format_string(FORMAT);
Ok(d)
+453
View File
@@ -0,0 +1,453 @@
//! Pre-allocation bounds for versioned analytic offset roles and boundary references.
use std::collections::BTreeSet;
use serde_json::Value;
use crate::{DocError, Document, FeatureKind, ProfileSel, Result};
fn invalid() -> DocError {
DocError::Invalid("damaged design: invalid analytic offset topology or boundary reference".into())
}
fn id(value: &Value) -> Result<&str> {
value.as_str().filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)).ok_or_else(invalid)
}
fn exact_keys(value: &Value, keys: &[&str]) -> Result<()> {
let object = value.as_object().ok_or_else(invalid)?;
if object.len() != keys.len() || object.keys().any(|key| !keys.contains(&key.as_str())) {
return Err(invalid());
}
Ok(())
}
/// Validate a complete-source profile reference before copying or decoding it.
pub fn whole_profile_selection_from_value(value: &Value) -> Result<ProfileSel> {
validate_whole_reference(value)?;
serde_json::from_value(value.clone()).map_err(|_| invalid())
}
fn validate_whole_reference(value: &Value) -> Result<()> {
exact_keys(value, &["type", "profiles"])?;
if value.get("type").and_then(Value::as_str) != Some("whole_boundaries") {
return Err(invalid());
}
let profiles = value.get("profiles").and_then(Value::as_array).ok_or_else(invalid)?;
if profiles.is_empty() || profiles.len() > 128 {
return Err(invalid());
}
let mut total = 0usize;
let mut identities = BTreeSet::new();
for profile in profiles {
exact_keys(profile, &["outer", "holes"])?;
let mut boundary = |value: &Value| -> Result<Vec<String>> {
let values = value.as_array().ok_or_else(invalid)?;
if values.is_empty() || values.len() > 512 {
return Err(invalid());
}
total += values.len();
if total > 20_000 {
return Err(invalid());
}
let mut previous = None;
// Bound each raw ID before any owned allocation. Require canonical order.
for value in values {
let current = id(value)?;
if previous.is_some_and(|previous| previous >= current) {
return Err(invalid());
}
previous = Some(current);
}
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
};
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
if holes.len() > 128 {
return Err(invalid());
}
let mut sorted_holes = Vec::with_capacity(holes.len());
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
for hole in holes {
let hole = boundary(hole)?;
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
return Err(invalid());
}
sorted_holes.push(hole);
}
if !identities.insert((outer, sorted_holes)) {
return Err(invalid());
}
}
Ok(())
}
pub(crate) fn validate_raw(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
let mut total_roles = 0usize;
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(offsets) = feature.get("sketch").and_then(|sketch| sketch.get("offsets")) else { continue };
let offsets = offsets.as_array().ok_or_else(invalid)?;
let mut groups = BTreeSet::new();
let mut outputs = BTreeSet::new();
let mut point_owners = std::collections::BTreeMap::new();
let mut sketch_roles = 0usize;
for offset in offsets {
let Some(topology) = offset.get("topology").filter(|topology| !topology.is_null()) else { continue };
if groups.len() >= 128 {
return Err(invalid());
}
exact_keys(topology, &["version", "id", "roles"])?;
let group = id(topology.get("id").ok_or_else(invalid)?)?;
if !groups.insert(group)
|| topology.get("version").and_then(Value::as_u64) != Some(1)
|| offset.get("closed").and_then(Value::as_bool) != Some(true)
{
return Err(invalid());
}
let distance = offset.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 {
return Err(invalid());
}
let sources = offset.get("sources").and_then(Value::as_array).ok_or_else(invalid)?;
let roles = topology.get("roles").and_then(Value::as_array).ok_or_else(invalid)?;
let active = offset.get("outputs").and_then(Value::as_array).ok_or_else(invalid)?;
if roles.is_empty() || roles.len() > 512 || roles.len() != sources.len() || active.len() > roles.len() {
return Err(invalid());
}
total_roles = total_roles.saturating_add(roles.len());
sketch_roles = sketch_roles.saturating_add(roles.len());
if total_roles > 1_000_000 || sketch_roles > 20_000 {
return Err(invalid());
}
let mut active_index = 0;
let mut source_ids = BTreeSet::new();
for (source, role) in sources.iter().zip(roles) {
let source = source.as_array().ok_or_else(invalid)?;
if source.len() != 2 || source.get(1).and_then(Value::as_bool).is_none() {
return Err(invalid());
}
let source = id(source.first().ok_or_else(invalid)?)?;
if !source_ids.insert(source) {
return Err(invalid());
}
let role_object = role.as_object().ok_or_else(invalid)?;
if role_object.keys().any(|key| !matches!(key.as_str(), "output" | "kind" | "start" | "end" | "retired" | "flags")) {
return Err(invalid());
}
if let Some(flags) = role.get("flags") {
exact_keys(flags, &["construction", "centerline", "reversed", "fixed"])?;
if flags.as_object().is_none_or(|flags| flags.values().any(|flag| flag.as_bool().is_none())) {
return Err(invalid());
}
}
let output = id(role.get("output").ok_or_else(invalid)?)?;
if source == output || !outputs.insert(output) {
return Err(invalid());
}
if role.get("start") == role.get("end") {
return Err(invalid());
}
for key in ["start", "end"] {
let point = id(role.get(key).ok_or_else(invalid)?)?;
if point == output || point == source || point_owners.insert(point, group).is_some_and(|owner| owner != group) {
return Err(invalid());
}
}
let kind = role.get("kind").and_then(Value::as_str).ok_or_else(invalid)?;
let retired = role.get("retired").and_then(Value::as_bool).ok_or_else(invalid)?;
if !matches!(kind, "line" | "arc") || (retired && kind != "arc") {
return Err(invalid());
}
if !retired {
if active.get(active_index).map(id).transpose()?.is_none_or(|id| id != output) {
return Err(invalid());
}
active_index += 1;
}
}
if active_index != active.len() || active_index < 3 {
return Err(invalid());
}
}
if groups.iter().any(|group| outputs.contains(group) || point_owners.contains_key(group))
|| outputs.iter().any(|output| point_owners.contains_key(output))
{
return Err(invalid());
}
}
fn references(value: &Value) -> Result<()> {
if value.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
return validate_whole_reference(value);
}
if value.get("type").and_then(Value::as_str) == Some("offset_boundaries") {
exact_keys(value, &["type", "associations"])?;
let ids = value.get("associations").and_then(Value::as_array).ok_or_else(invalid)?;
if ids.is_empty() || ids.len() > 128 {
return Err(invalid());
}
let mut seen = BTreeSet::new();
for value in ids {
if !seen.insert(id(value)?) {
return Err(invalid());
}
}
}
Ok(())
}
// Only feature profile fields can contain this variant. Do not impose a new
// document-wide node limit on unrelated legacy sketches or imported payloads.
for feature in features {
if let Some(selection) = feature.get("profiles") {
references(selection)?;
}
if let Some(sections) = feature.get("sections").and_then(Value::as_array) {
if sections.len() > 10_000 {
return Err(invalid());
}
for section in sections {
if let Some(selection) = section.get("profiles") {
references(selection)?;
}
}
}
}
Ok(())
}
pub(crate) fn validate_typed(document: &Document) -> Result<()> {
let mut sketches = std::collections::BTreeMap::new();
for feature in &document.features {
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_offset_topologies()?;
sketches.insert(feature.id, sketch);
}
for (sketch, selection) in feature.kind.profile_sels() {
if let ProfileSel::OffsetBoundaries { associations } = selection {
// A deleted association or source sketch is a repairable model error,
// just like an ordinary missing curve reference. Keep that design loadable.
let Some(sketch) = sketches.get(&sketch) else { continue };
for association in associations {
let count =
sketch.offsets.iter().filter_map(|offset| offset.topology.as_ref()).filter(|topology| &topology.id == association).count();
if count > 1 {
return Err(invalid());
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use serde_json::json;
fn fixture() -> Value {
json!({"features":[{"type":"sketch","sketch":{"offsets":[{
"sources":[["s0",true],["s1",true],["s2",true],["s3",true]],
"outputs":["l0","l1","l2"],"closed":true,"distance":1.4,
"topology":{"version":1,"id":"o1","roles":[
{"output":"l0","kind":"line","start":"p0","end":"p1","retired":false},
{"output":"a0","kind":"arc","start":"p1","end":"p2","retired":true},
{"output":"l1","kind":"line","start":"p2","end":"p3","retired":false},
{"output":"l2","kind":"line","start":"p3","end":"p0","retired":false}
]}
}]}}]})
}
#[test]
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
let malformed = [
json!({"type":"whole_boundaries","profiles":[]}),
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
];
for value in malformed {
assert!(whole_profile_selection_from_value(&value).is_err(), "accepted malformed reference {value}");
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_err());
}
let many = (0..129).map(|index| json!({"outer":[format!("c{index:03}")],"holes":[]})).collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":&many[..128]})).is_ok());
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":many})).is_err());
let oversized = (0..513).map(|index| format!("c{index:03}")).collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":oversized,"holes":[]}]})).is_err());
let too_many_holes = (0..129).map(|index| vec![format!("hole{index:03}")]).collect::<Vec<_>>();
assert!(
whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":["outer"],"holes":too_many_holes}]})).is_err()
);
let aggregate = (0..40)
.map(|profile| {
let outer = (0..512).map(|curve| format!("profile{profile:03}curve{curve:03}")).collect::<Vec<_>>();
json!({"outer":outer,"holes":[]})
})
.collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
}
#[test]
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
let good = fixture();
assert!(validate_raw(&good).is_ok());
let path = "/features/0/sketch/offsets/0";
let mut flags = good.clone();
flags.pointer_mut(&format!("{path}/topology/roles/1")).unwrap()["flags"] =
json!({"construction":true,"centerline":false,"reversed":true,"fixed":false});
assert!(validate_raw(&flags).is_ok());
*flags.pointer_mut(&format!("{path}/topology/roles/1/flags/construction")).unwrap() = json!(1);
assert!(validate_raw(&flags).is_err());
for (suffix, replacement) in [
("/topology/version", json!(2)),
("/topology/id", json!("x".repeat(513))),
("/closed", json!(false)),
("/distance", json!(1e9)),
("/outputs", json!(["l1", "l0", "l2"])),
("/outputs", json!(["l0", "a0", "l1", "l2"])),
("/topology/roles/1/kind", json!("line")),
("/topology/roles/1/output", json!("l0")),
("/topology/roles/1/start", json!("a0")),
("/topology/roles/0/kind", json!("guess")),
("/sources/0/1", json!(0)),
("/sources/0/0", json!("l0")),
("/topology/roles", json!(vec![json!({}); 513])),
] {
let mut damaged = good.clone();
*damaged.pointer_mut(&format!("{path}{suffix}")).unwrap() = replacement;
assert!(validate_raw(&damaged).is_err(), "accepted {suffix}");
}
let mut extra = good.clone();
extra.pointer_mut(&format!("{path}/topology")).unwrap()["fallback"] = json!([]);
assert!(validate_raw(&extra).is_err());
let mut excessive = good.clone();
let chain = excessive.pointer(path).unwrap().clone();
let groups = (0..129)
.map(|index| {
let mut chain = chain.clone();
chain["topology"]["id"] = json!(format!("o{index}"));
for source in chain["sources"].as_array_mut().unwrap() {
source[0] = json!(format!("g{index}-{}", source[0].as_str().unwrap()));
}
for output in chain["outputs"].as_array_mut().unwrap() {
*output = json!(format!("g{index}-{}", output.as_str().unwrap()));
}
for role in chain["topology"]["roles"].as_array_mut().unwrap() {
for key in ["output", "start", "end"] {
role[key] = json!(format!("g{index}-{}", role[key].as_str().unwrap()));
}
}
chain
})
.collect::<Vec<_>>();
excessive["features"][0]["sketch"]["offsets"] = json!(&groups[..128]);
assert!(validate_raw(&excessive).is_ok(), "the permitted group boundary must be accepted");
excessive["features"][0]["sketch"]["offsets"] = json!(groups);
assert!(validate_raw(&excessive).is_err());
}
#[test]
fn raw_boundary_references_are_bounded_and_future_versions_refuse_first() {
let mut good = fixture();
good["features"].as_array_mut().unwrap().push(json!({"type":"extrude","profiles":{"type":"offset_boundaries","associations":["o1"]}}));
assert!(validate_raw(&good).is_ok());
for associations in [json!([]), json!(["o1", "o1"]), json!(["x".repeat(513)]), json!(vec!["o"; 129]), json!([0])] {
let mut damaged = good.clone();
damaged["features"][1]["profiles"]["associations"] = associations;
assert!(validate_raw(&damaged).is_err());
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolveCraft"));
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
legacy["features"][0]["sketch"]["offsets"][0].as_object_mut().unwrap().remove("topology");
legacy["features"][0]["sketch"]["offsets"][0]["closed"] = json!(false);
assert!(validate_raw(&legacy).is_ok());
}
#[test]
fn formats_one_through_eleven_keep_legacy_offset_geometry_and_ids() {
let mut sketch = solvecraft_sketch::Sketch::new();
let center = sketch.add_point(solvecraft_geom::Vec2::new(4., 7.), Some("center")).unwrap();
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 10., Some(center), Some("source")).unwrap();
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 9., Some(center), Some("offset")).unwrap();
sketch.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![("source".into(), true)],
outputs: vec!["offset".into()],
distance: 1.,
closed: true,
topology: None,
});
let mut document = Document::new("legacy offset circles");
let sketch_id =
document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Circles")).unwrap();
let original: Value = serde_json::from_str(&document.to_json()).unwrap();
for version in 1..=11 {
let mut old = original.clone();
old["format"] = json!(crate::format::format_string(version));
let upgraded = crate::format::read(&old.to_string()).unwrap();
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(saved["features"][0]["sketch"]["offsets"], original["features"][0]["sketch"]["offsets"]);
assert_eq!(saved["features"][0]["sketch"]["curves"], original["features"][0]["sketch"]["curves"]);
assert_eq!(saved["features"][0]["sketch"]["points"], original["features"][0]["sketch"]["points"]);
let mut model = crate::Model::new();
model.evaluate(&upgraded);
assert!(model.results.iter().all(|result| result.error.is_none()));
let state = model.state();
let solved = state.sketch(sketch_id).unwrap();
let radius = solved.sketch.curves.iter().find(|curve| curve.id == "offset").map(|curve| &curve.kind);
assert!(matches!(radius,Some(solvecraft_sketch::CurveKind::Circle {r,..}) if *r ==9.));
}
}
#[test]
fn missing_boundary_or_defining_sketch_reopens_as_a_model_error_without_retarget() {
for missing_sketch in [false, true] {
let mut sketch = solvecraft_sketch::Sketch::new();
sketch.add_circle(solvecraft_geom::Vec2::new(0., 0.), 10., None, Some("unrelated-circle")).unwrap();
let mut document = Document::new("repairable lost boundary");
let id = document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
document
.add_feature(
FeatureKind::Extrude {
sketch: if missing_sketch { 999_999 } else { id },
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let reopened = crate::format::read(&document.to_json()).unwrap();
assert_eq!(reopened, document);
let mut model = crate::Model::new();
model.evaluate(&reopened);
assert!(model.results.last().unwrap().error.is_some());
assert!(model.state().bodies.is_empty(), "lost boundary must not select the remaining ordinary circle");
}
}
}
+4
View File
@@ -21,10 +21,14 @@ pub mod expr;
pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
pub mod motion;
pub mod naming;
mod params;
mod profile_refs;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
mod project3d;
+31 -2
View File
@@ -331,16 +331,20 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
if let Some(f) = idx.face_at(p, tol)
&& let Some(n) = nm.get(f)
{
// A later operation may merge several pieces of the same input face.
// Count their shared ancestry before testing the majority; a piece
// number identifies topology subdivision, not a different source.
let n = strip_piece(n);
match votes.iter_mut().find(|(v, _)| v == n) {
Some(v) => v.1 += 1,
None => votes.push((n.clone(), 1)),
None => votes.push((n.to_string(), 1)),
}
break;
}
}
}
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string())
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
}
/// A face name without its piece number (`#k`).
@@ -722,3 +726,28 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
}
best.map(|(_, index, q, split)| (index, q, split))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merged_face_votes_collectively_for_pieces_of_one_source() {
let body = ModelBody::new("Input".into(), solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
let mut mesh = Mesh::default();
let mut points = Vec::new();
for i in 0..3u32 {
let x = f64::from(i);
mesh.positions.extend([Vec3::new(x, 0., 0.), Vec3::new(x + 0.8, 0., 0.), Vec3::new(x, 0.8, 0.)]);
mesh.triangles.push([i * 3, i * 3 + 1, i * 3 + 2]);
mesh.tri_face.push(i);
points.push(Vec3::new(x + 0.2, 0.2, 0.));
}
let pieces = Arc::new(vec!["F1:side:wall#1".into(), "F1:side:wall#2".into(), "F1:side:wall#3".into()]);
assert_eq!(vote(&points, &[(&body, FaceIndex::new(&mesh, 3), pieces)], 1e-8, None).as_deref(), Some("F1:side:wall"));
let distinct = Arc::new(vec!["F1:side:wall#1".into(), "F2:side:wall#2".into(), "F3:side:wall#3".into()]);
let inputs = [(&body, FaceIndex::new(&mesh, 3), distinct)];
assert_eq!(vote(&points, &inputs, 1e-8, None), None);
assert_eq!(vote(&points[..2], &inputs, 1e-8, None), None, "equal different-source votes must not pick a lexicographic winner");
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Exact profile references through analytic offset topology transitions.
use std::collections::BTreeSet;
use crate::{DocError, ProfileSel, Result, SolvedSketch, WholeProfileBoundary};
const MAX_SELECTIONS: usize = 128;
fn whole_boundary(profile: &solvecraft_sketch::Profile) -> Option<WholeProfileBoundary> {
let (outer, holes) = profile.whole_curve_boundaries()?;
Some(WholeProfileBoundary { outer, holes })
}
fn validate_whole(profiles: &[WholeProfileBoundary]) -> Result<()> {
if profiles.is_empty() || profiles.len() > MAX_SELECTIONS {
return Err(DocError::Invalid("whole-source profile selection needs bounded unique profiles".into()));
}
let mut total = 0usize;
let mut identities = BTreeSet::new();
for profile in profiles {
if profile.holes.len() > 128 || !identities.insert((&profile.outer, &profile.holes)) {
return Err(DocError::Invalid("invalid whole-source profile holes or duplicate identity".into()));
}
let mut all = BTreeSet::new();
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
total += boundary.len();
if boundary.is_empty() || boundary.len() > 512 || total > 20_000 {
return Err(DocError::Invalid("whole-source boundary exceeds its identity budget".into()));
}
let mut previous = None;
for curve in boundary {
if curve.is_empty()
|| curve.len() > 512
|| curve.chars().any(char::is_control)
|| previous.is_some_and(|previous| previous >= curve)
|| !all.insert(curve)
{
return Err(DocError::Invalid("whole-source boundary needs canonical disjoint curve identities".into()));
}
previous = Some(curve);
}
}
if profile.holes.windows(2).any(|pair| pair[0] >= pair[1]) {
return Err(DocError::Invalid("whole-source holes need canonical unique identities".into()));
}
}
Ok(())
}
fn whole_profile_indices(ss: &SolvedSketch, profiles: &[WholeProfileBoundary]) -> Result<Vec<usize>> {
validate_whole(profiles)?;
let current = ss.profiles.iter().map(whole_boundary).collect::<Vec<_>>();
profiles
.iter()
.map(|identity| {
let mut matches = current.iter().enumerate().filter(|(_, candidate)| candidate.as_ref() == Some(identity));
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("complete source profile in {}", ss.name)))?;
if matches.next().is_some() {
return Err(DocError::Invalid(format!("ambiguous complete source profile in {}", ss.name)));
}
Ok(index)
})
.collect()
}
fn same_ids(a: &[String], b: &[String]) -> bool {
if a.len() != b.len() {
return false;
}
let (mut a, mut b) = (a.iter().collect::<Vec<_>>(), b.iter().collect::<Vec<_>>());
a.sort();
b.sort();
a == b
}
fn unique_profile(ss: &SolvedSketch, curves: &[String]) -> Result<usize> {
let mut matches = ss.profiles.iter().enumerate().filter(|(_, profile)| same_ids(curves, &profile.outer_curves));
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?;
if matches.next().is_some() {
return Err(DocError::Invalid(format!("ambiguous profile boundary in {}", ss.name)));
}
Ok(index)
}
fn reserved_boundaries(ss: &SolvedSketch) -> Result<std::collections::BTreeMap<Vec<String>, String>> {
let mut found = std::collections::BTreeMap::new();
for offset in &ss.sketch.offsets {
let Some(topology) = &offset.topology else { continue };
let mut reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
reserved.sort();
if found.insert(reserved, topology.id.clone()).is_some() {
return Err(DocError::Invalid("profile role set belongs to multiple offset associations".into()));
}
}
Ok(found)
}
/// Resolve exact selection identities to the solved sketch's current profile indices.
/// Complete reserved role sets follow their association; partial sets never expand.
pub fn profile_selection_indices(ss: &SolvedSketch, selection: &ProfileSel) -> Result<Vec<usize>> {
let indices = match selection {
ProfileSel::All => (0..ss.profiles.len()).collect(),
ProfileSel::Indices { indices } => indices.clone(),
ProfileSel::Curves { loops } => {
let boundaries = reserved_boundaries(ss)?;
let mut indices = Vec::with_capacity(loops.len());
for curves in loops {
let mut key = curves.clone();
key.sort();
if let Some(id) = boundaries.get(&key) {
if curves.len() > 512 || curves.iter().collect::<BTreeSet<_>>().len() != curves.len() {
return Err(DocError::Invalid("offset profile has repeated or excessive curve roles".into()));
}
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
} else {
// Preserve the historical first exact ordinary-loop match.
indices.push(
ss.profiles
.iter()
.position(|profile| same_ids(curves, &profile.outer_curves))
.ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?,
);
}
}
indices
}
ProfileSel::OffsetBoundaries { associations } => {
if associations.is_empty()
|| associations.len() > MAX_SELECTIONS
|| associations.iter().collect::<BTreeSet<_>>().len() != associations.len()
{
return Err(DocError::Invalid("offset profile selection needs unique bounded association identities".into()));
}
associations.iter().map(|id| unique_profile(ss, &ss.sketch.offset_boundary(id)?)).collect::<Result<Vec<_>>>()?
}
ProfileSel::WholeBoundaries { profiles } => whole_profile_indices(ss, profiles)?,
ProfileSel::Points { points } => {
let mut indices = Vec::with_capacity(points.len());
for point in points {
let (index, _) = ss
.profiles
.iter()
.enumerate()
.filter(|(_, profile)| profile.region.contains(*point))
.min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area))
.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?;
indices.push(index);
}
indices
}
};
if indices.iter().any(|index| *index >= ss.profiles.len()) {
return Err(DocError::Invalid(format!("out-of-range profile selection in {}", ss.name)));
}
Ok(indices)
}
/// Persist selected complete offset boundaries by association identity. Mixed selections
/// retain exact ordinary loops and the complete reserved role set of each offset boundary.
pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result<ProfileSel> {
let checked = profile_selection_indices(ss, &ProfileSel::Indices { indices: indices.to_vec() })?;
if checked.is_empty() || checked.iter().collect::<BTreeSet<_>>().len() != checked.len() {
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
}
// Capture adaptive free-form boundaries only with complete source provenance,
// including every hole. Ordinary analytic captures keep their historical form.
let adaptive = checked.iter().any(|index| {
ss.profiles.get(*index).is_some_and(|profile| {
std::iter::once(&profile.outer_curves)
.chain(&profile.hole_curves)
.any(|curves| curves.iter().collect::<BTreeSet<_>>().len() != curves.len())
})
});
if adaptive {
let identities = checked.iter().map(|index| ss.profiles.get(*index).and_then(whole_boundary)).collect::<Option<Vec<_>>>();
if let Some(profiles) = identities {
// Dynamic analytic offset groups need their own association identities.
// Do not silently flatten a mixed selection into current active curve IDs.
let mut dynamic = false;
for profile in &profiles {
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
dynamic |= ss.sketch.offset_boundary_for_curves(boundary)?.is_some();
}
}
if !dynamic && whole_profile_indices(ss, &profiles)? == checked {
return Ok(ProfileSel::WholeBoundaries { profiles });
}
}
}
let mut associations = Vec::new();
let mut loops = Vec::new();
for index in checked {
let curves = &ss.profiles.get(index).ok_or_else(|| DocError::Invalid("profile index changed during capture".into()))?.outer_curves;
if curves.is_empty() {
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
}
// Ordinary free-form loops retain one curve ID per exact span. That
// historical multiset is valid profile identity, but is not a candidate
// for the unique reserved-role set of a topology-aware association.
let association =
if curves.iter().collect::<BTreeSet<_>>().len() == curves.len() { ss.sketch.offset_boundary_for_curves(curves)? } else { None };
if let Some(id) = association {
unique_profile(ss, curves)?;
let offset = ss
.sketch
.offsets
.iter()
.filter_map(|offset| offset.topology.as_ref())
.find(|topology| topology.id == id)
.ok_or_else(|| DocError::Unknown(format!("offset association {id}")))?;
loops.push(offset.roles.iter().map(|role| role.output.clone()).collect());
associations.push(id);
} else {
if ss.profiles.iter().filter(|profile| same_ids(curves, &profile.outer_curves)).count() != 1 {
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
}
loops.push(curves.clone());
}
}
if associations.len() > MAX_SELECTIONS {
return Err(DocError::Invalid("too many offset association profiles".into()));
}
if associations.len() == loops.len() { Ok(ProfileSel::OffsetBoundaries { associations }) } else { Ok(ProfileSel::Curves { loops }) }
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use solvecraft_geom::{Plane, Vec2};
use solvecraft_sketch::{Sketch, find_profiles, solve};
fn rounded_source(sketch: &mut Sketch, radius: f64) -> Vec<(String, bool)> {
let endpoints = [
Vec2::new(-37., -32. - radius),
Vec2::new(37., -32. - radius),
Vec2::new(37. + radius, -32.),
Vec2::new(37. + radius, 32.),
Vec2::new(37., 32. + radius),
Vec2::new(-37., 32. + radius),
Vec2::new(-37. - radius, 32.),
Vec2::new(-37. - radius, -32.),
];
let points: Vec<_> =
endpoints.iter().enumerate().map(|(index, point)| sketch.add_point(*point, Some(&format!("source-p{index}"))).unwrap()).collect();
let centers = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)];
for index in 0..8 {
let id = format!("source-edge{index}");
if index % 2 == 0 {
sketch.add_line_pts(points[index], points[(index + 1) % 8], Some(&id)).unwrap();
} else {
let center = centers[index / 2];
sketch
.add_arc(
center,
endpoints[index],
endpoints[(index + 1) % 8],
[None, Some(points[index]), Some(points[(index + 1) % 8])],
Some(&id),
)
.unwrap();
}
}
for point in &mut sketch.points {
point.fixed = true;
}
(0..8).map(|index| (format!("source-edge{index}"), true)).collect()
}
fn set_source_radius(sketch: &mut Sketch, radius: f64) {
let points = [
Vec2::new(-37., -32. - radius),
Vec2::new(37., -32. - radius),
Vec2::new(37. + radius, -32.),
Vec2::new(37. + radius, 32.),
Vec2::new(37., 32. + radius),
Vec2::new(-37., 32. + radius),
Vec2::new(-37. - radius, 32.),
Vec2::new(-37. - radius, -32.),
];
for (index, value) in points.into_iter().enumerate() {
sketch.points.iter_mut().find(|point| point.id == format!("source-p{index}")).unwrap().pos = value;
}
}
#[test]
fn captured_sharp_offset_profile_survives_arc_resurrection_and_reopen() {
let mut sketch = Sketch::new();
let sources = rounded_source(&mut sketch, 1.);
let created = sketch.add_convex_offset(sources, 1.4).unwrap();
assert_eq!(created.curves.len(), 4);
let mut document = crate::Document::new("selected logical inner boundary");
let feature =
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let mut model = crate::Model::new();
model.evaluate(&document);
let state = model.state();
let solved = state.sketch(feature).unwrap();
let index = unique_profile(solved, &created.curves).unwrap();
let selection = capture_profile_selection(solved, &[index]).unwrap();
assert_eq!(selection, ProfileSel::OffsetBoundaries { associations: vec![created.association.clone()] });
let original_roles = solved.sketch.offsets[0].topology.as_ref().unwrap().roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
let sharp_area = crate::select_profiles(solved, &selection).unwrap()[0].area();
assert!((sharp_area - 73.2 * 63.2).abs() < 1e-7, "{sharp_area}");
document
.add_feature(
crate::FeatureKind::Extrude {
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
model.evaluate(&document);
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
assert!((volume - 2. * 73.2 * 63.2).abs() < 1e-6, "{volume}");
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
assert_eq!(reopened, document);
let mut cold = crate::Model::new();
cold.evaluate(&reopened);
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-6);
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
set_source_radius(sketch, 2.);
model.evaluate(&document);
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
let state = model.state();
let solved = state.sketch(feature).unwrap();
let active = solved.sketch.offset_boundary(&created.association).unwrap();
assert_eq!(active, original_roles);
let rounded_area = 75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6;
assert!((crate::select_profiles(solved, &selection).unwrap()[0].area() - rounded_area).abs() < 1e-7);
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
// Kernel volume integrates the curved display mesh; the analytic region above
// checks the exact rounded geometry independently of that tessellation.
assert!((volume - 2. * rounded_area).abs() < 0.002, "{volume}");
assert!(
crate::select_profiles(solved, &ProfileSel::Curves { loops: vec![created.curves.clone()] }).is_err(),
"partial old active set must not expand after resurrection"
);
assert_eq!(
profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![original_roles] }).unwrap(),
profile_selection_indices(solved, &selection).unwrap()
);
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
cold.evaluate(&reopened);
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-5);
}
#[test]
fn ordinary_profile_selection_keeps_legacy_large_and_duplicate_semantics() {
let mut sketch = Sketch::new();
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
assert_eq!(ss.profiles.len(), 1);
let indices = vec![0; 600];
let selection = ProfileSel::Indices { indices: indices.clone() };
assert_eq!(profile_selection_indices(&ss, &selection).unwrap(), indices);
assert_eq!(capture_profile_selection(&ss, &indices).unwrap(), selection);
let points = ProfileSel::Points { points: vec![Vec2::new(0., 0.); 600] };
assert_eq!(profile_selection_indices(&ss, &points).unwrap(), indices);
let curves = ProfileSel::Curves { loops: vec![vec!["circle".into()]; 600] };
let regions = crate::select_profiles(&ss, &curves).unwrap();
assert_eq!(regions.len(), 600);
assert!(regions.iter().all(|region| (region.area() - std::f64::consts::PI * 9.).abs() < 1e-9));
assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err());
}
#[test]
fn periodic_offset_ring_whole_source_survives_adaptive_spans_and_reopen() {
let mut sketch = Sketch::new();
let major = sketch.add_point(Vec2::new(10., 0.), None).unwrap();
let ellipse = sketch.add_curve(solvecraft_sketch::CurveKind::Ellipse { c: 0, m: major, r: 5. }, None).unwrap();
let first = sketch.add_freeform_offset(ellipse, true, -1.).unwrap();
sketch.add_freeform_offset(first, true, -0.5).unwrap();
let mut document = crate::Document::new("periodic offset ring multiset");
let feature =
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let mut model = crate::Model::new();
model.evaluate(&document);
let state = model.state();
let solved = state.sketch(feature).unwrap();
assert_eq!(solved.profiles.len(), 3);
let (index, profile) = solved.profiles.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap();
assert!(profile.outer_curves.len() > 1);
assert_eq!(profile.outer_curves.iter().collect::<BTreeSet<_>>().len(), 1);
assert!(solved.sketch.offset_boundary_for_curves(&profile.outer_curves).is_err(), "strict association identity still refuses repeated roles");
let selection = capture_profile_selection(solved, &[index]).unwrap();
assert!(matches!(&selection, ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1 && profiles[0].holes.len() == 1));
let legacy_spans = ProfileSel::Curves { loops: vec![profile.outer_curves.clone()] };
assert_eq!(profile_selection_indices(solved, &legacy_spans).unwrap(), vec![index]);
let legacy_roundtrip: ProfileSel = serde_json::from_str(&serde_json::to_string(&legacy_spans).unwrap()).unwrap();
assert_eq!(legacy_roundtrip, legacy_spans, "ordinary legacy span multiplicities remain serialized exactly");
assert_eq!(profile_selection_indices(solved, &selection).unwrap(), vec![index]);
let selected = crate::select_profiles(solved, &selection).unwrap();
assert_eq!(selected.len(), 1);
assert!((selected[0].area() - profile.area).abs() < 1e-10);
assert!(selected[0].contains(Vec2::new(11.25, 0.)));
assert!(!selected[0].contains(Vec2::new(10.5, 0.)));
assert!(!selected[0].contains(Vec2::new(11.75, 0.)));
let saved = serde_json::to_string(&selection).unwrap();
let reopened: ProfileSel = serde_json::from_str(&saved).unwrap();
let reopened_doc = crate::Document::from_json(&document.to_json()).unwrap();
let mut cold = crate::Model::new();
cold.evaluate(&reopened_doc);
let cold_state = cold.state();
let regions = crate::select_profiles(cold_state.sketch(feature).unwrap(), &reopened).unwrap();
assert_eq!(regions, selected);
// An identical complete identity in two profiles is ambiguous, even when
// their boundary geometry and graph coverage certificates are identical.
let mut ambiguous = cold_state.sketch(feature).unwrap().clone();
ambiguous.profiles.push(ambiguous.profiles[index].clone());
assert!(profile_selection_indices(&ambiguous, &reopened).is_err());
document
.add_feature(
crate::FeatureKind::Extrude {
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
model.evaluate(&document);
let initial_volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
// Kernel measurement integrates the curved mesh. Scale the established
// periodic-ring tolerance (0.05 mm³ at height3) to this height2 extrusion.
// Exact profile identity and cold/warm equality below remain strict.
let volume_tolerance = 0.05 * 2. / 3.;
assert!(
(initial_volume - 2. * profile.area).abs() < volume_tolerance,
"initial kernel volume {initial_volume}, analytic area {}",
profile.area
);
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
sketch.points[major].pos.x = 12.;
model.evaluate(&document);
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
let updated_state = model.state();
let updated = updated_state.sketch(feature).unwrap();
assert!(profile_selection_indices(updated, &legacy_spans).is_err(), "legacy tessellation-span multiplicity intentionally remains exact");
let updated_index = profile_selection_indices(updated, &selection).unwrap()[0];
let updated_region = &updated.profiles[updated_index].region;
// Probe away from exact spline-span seams, using the independent analytic
// ellipse point and unit outward gradient rather than a stored profile sample.
let parameter = 0.2_f64;
let source_point = Vec2::new(12. * parameter.cos(), 5. * parameter.sin());
let normal = Vec2::new(parameter.cos() / 12., parameter.sin() / 5.).normalized().unwrap();
let ring_point = source_point + normal * 1.25;
assert!(updated_region.contains(ring_point));
assert!(!updated_region.contains(source_point + normal * 0.5));
assert!(!updated_region.contains(source_point + normal * 1.75));
let updated_volume = solvecraft_kernel::measure(&updated_state.bodies[0].body).unwrap().volume;
assert!(updated_volume > initial_volume);
assert!(
(updated_volume - 2. * updated_region.area()).abs() < volume_tolerance,
"updated kernel volume {updated_volume}, analytic area {}",
updated_region.area()
);
let reopened_document = crate::Document::from_json(&document.to_json()).unwrap();
cold.evaluate(&reopened_document);
assert!(cold.results.iter().all(|result| result.error.is_none()));
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - updated_volume).abs() < 1e-6);
// A new hole is a semantic boundary change, not the old ring. Whole-source
// identities include holes and must not silently absorb the new material cutout.
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
sketch.add_circle(ring_point, 0.1, None, Some("new-hole")).unwrap();
model.evaluate(&document);
let changed = model.state();
assert!(profile_selection_indices(changed.sketch(feature).unwrap(), &selection).is_err());
assert!(model.results.last().unwrap().error.is_some());
}
}
+92 -6
View File
@@ -32,7 +32,7 @@ impl FlatWork {
pub(crate) fn new() -> Self {
Self(MAX_FLAT_WORK)
}
fn charge(&mut self, work: usize) -> Result<()> {
pub(crate) fn charge(&mut self, work: usize) -> Result<()> {
self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
Ok(())
}
@@ -754,16 +754,88 @@ impl SheetBody {
}
let mut work = FlatWork::new();
let cells = self.cells_with_work(&mut work)?;
let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?;
self.machine_pockets(body, &mut work)
// Validate the bounded material first, then build close to the sheet's own
// origin. Fitting planes after a distant component transfer loses precision.
let mut local = self.clone();
local.frame = Plane::XY;
let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
let body = local.machine_pockets(body, &mut work)?;
self.place_local_solid(body)
}
/// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> {
let regions = self.flat_regions()?;
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
let body = kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?;
self.machine_pockets(body, &mut FlatWork::new())
let mut local = self.clone();
local.frame = Plane::XY;
local.flat = true;
let body = kernel::extrude_planar_regions(&Plane::XY, &regions, 0.0, self.t)?;
let body = local.machine_pockets(body, &mut FlatWork::new())?;
self.place_local_solid(body)
}
fn place_local_solid(&self, body: Body) -> Result<Body> {
if self.frame == Plane::XY { Ok(body) } else { Ok(kernel::transform_matrix(&body, self.frame_mat())?) }
}
/// Retain bounded derived machining records, without duplicating identical replay work.
pub(crate) fn record_pocket(&mut self, region: Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<()> {
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
let pocket = SheetPocket { region, z0, z1 };
let (old_edges, old_points) = regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let (new_edges, new_points) = region_complexity(std::slice::from_ref(&pocket.region))?;
work.charge(
old_edges.saturating_add(old_points).saturating_add(new_edges.saturating_add(new_points).saturating_mul(self.pockets.len().max(1))),
)?;
if self.pockets.contains(&pocket) {
return Ok(());
}
if self.pockets.len() >= 1000 {
return Err(flat_budget_error());
}
if old_edges.saturating_add(new_edges) > MAX_FLAT_POINTS || old_points.saturating_add(new_points) > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
self.pockets.push(pocket);
Ok(())
}
/// Certify whether a planar panel has material outside a retained thickness interval.
/// Classification uses exact planar arrangements at every pocket depth boundary.
pub(crate) fn material_outside_depth(&self, panel: &Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<bool> {
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
return Err(DocError::Invalid("invalid sheet retained depth".into()));
}
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let mut levels = vec![0.0, self.t, z0, z1];
for p in &self.pockets {
if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
levels.extend([p.z0, p.z1]);
}
levels.sort_by(f64::total_cmp);
levels.dedup();
work.charge(levels.len().saturating_mul(self.pockets.len().max(1)))?;
for zs in levels.windows(2) {
if zs[0] >= z0 && zs[1] <= z1 {
continue;
}
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
let removed = material_union(active, work)?;
if !material_boolean(std::slice::from_ref(panel), &removed, kernel::BoolOp::Cut, work)?.is_empty() {
return Ok(true);
}
}
Ok(false)
}
/// Replay exact planar machining after forming the full-thickness material.
@@ -1963,4 +2035,18 @@ mod tests {
assert!(s.flat_solid().unwrap_err().to_string().contains("depth"));
}
}
#[test]
fn pocket_recording_deduplicates_at_limit_and_rejects_growth_before_mutation() {
let mut s = plate(100.0, 60.0);
let pocket =
SheetPocket { region: rect(Vec2::new(1.0, 1.0), Vec2::new(2.0, 1.0), Vec2::new(2.0, 2.0), Vec2::new(1.0, 2.0)), z0: 0.0, z1: 0.5 };
s.pockets = vec![pocket.clone(); 1000];
let mut work = FlatWork::new();
s.record_pocket(pocket.region.clone(), pocket.z0, pocket.z1, &mut work).unwrap();
assert_eq!(s.pockets.len(), 1000);
assert!(s.record_pocket(pocket.region.clone(), 0.0, 0.6, &mut work).is_err());
assert_eq!(s.pockets.len(), 1000);
s.pockets.push(pocket.clone());
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
}
}