feat: preserve adaptive sketch profiles and curved modeling workflows
This commit is contained in:
1 parent
4c4a0449a4
commit
4e595deff2
62 files changed
+7114
-362
No files matched your search
@@ -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, §ion.profiles)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
|
||||
match self {
|
||||
FeatureKind::Shell { faces, .. }
|
||||
|
||||
+63
-17
@@ -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 ®ions {
|
||||
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
@@ -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(¤t) {
|
||||
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 ®ions {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ®ions, 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, ®ions, 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user