feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+186 -4
View File
@@ -3,16 +3,162 @@ use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
use serde::{Deserialize, Serialize};
use solvecraft_geom::Seg2;
fn valid_signature(outer: &[String], holes: &[Vec<String>]) -> bool {
let valid = |ids: &[String]| {
!ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1])
};
valid(outer)
&& holes.len() <= 128
&& holes.iter().all(|ids| valid(ids))
&& outer.len() + holes.iter().map(Vec::len).sum::<usize>() <= 2000
&& holes.windows(2).all(|pair| pair[0] < pair[1])
&& holes.iter().flatten().all(|id| outer.binary_search(id).is_err())
&& holes.iter().flatten().collect::<std::collections::BTreeSet<_>>().len() == holes.iter().map(Vec::len).sum::<usize>()
}
fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool {
let valid = |value: &serde_json::Value| {
value.as_array().is_some_and(|ids| {
!ids.is_empty()
&& ids.len() <= 2000
&& ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128))
&& ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
})
};
let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else {
return false;
};
let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else {
return false;
};
if !holes.iter().all(valid) {
return false;
}
let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::<usize>();
let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect();
total <= 2000
&& holes
.iter()
.filter_map(serde_json::Value::as_array)
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<std::collections::BTreeSet<_>>()
.len()
== total - outer.len()
&& holes
.iter()
.filter_map(serde_json::Value::as_array)
.flatten()
.filter_map(serde_json::Value::as_str)
.all(|id| ids.binary_search(&id).is_err())
&& holes.windows(2).all(|pair| {
let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
a < b
})
}
pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) {
hash.update((ids.len() as u64).to_le_bytes());
for id in ids {
hash.update((id.len() as u64).to_le_bytes());
hash.update(id.as_bytes());
}
}
format!("{:x}", hash.finalize())
}
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
return None;
}
let signature = |outline: &solvecraft_geom::Loop2| {
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
ids.sort();
ids.dedup();
Some(ids)
};
let outer = signature(&region.outer)?;
let mut holes = region.holes.iter().map(signature).collect::<Option<Vec<_>>>()?;
holes.sort();
valid_signature(&outer, &holes).then_some((outer, holes))
}
/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified.
type CapBindings = Vec<Vec<Option<solvecraft_kernel::CapSignature>>>;
pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option<CapBindings> {
use solvecraft_kernel::FaceSource;
if sketch.sketch.curves.len() > 2000 {
return None;
}
let mut faces = 0usize;
let mut comparisons = 0usize;
for body in bodies {
for source in body.face_sources().into_iter().flatten() {
faces = faces.checked_add(1)?;
if faces > 50_000 {
return None;
}
let segments = match source {
Some(FaceSource::ExtrudeSide { .. }) => 1,
Some(FaceSource::ExtrudeCap { region, .. }) => {
region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>())?
}
_ => 0,
};
comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?;
if comparisons > budget {
return None;
}
}
}
let mut counts = std::collections::BTreeMap::new();
let mut bindings = Vec::with_capacity(bodies.len());
for body in bodies {
let mut cached: Option<(&solvecraft_geom::Region2, Option<solvecraft_kernel::CapSignature>)> = None;
let mut values = Vec::new();
for source in body.face_sources().into_iter().flatten() {
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
let signature = match &cached {
Some((previous, signature)) if *previous == region => signature.clone(),
_ => exact_region_signature(sketch, region),
};
cached = Some((region, signature.clone()));
if let Some((outer, holes)) = &signature {
*counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1;
}
signature
} else {
None
};
values.push(signature);
}
bindings.push(values);
}
for (body, values) in bodies.iter().zip(&mut bindings) {
for (source, value) in body.face_sources().into_iter().flatten().zip(values) {
if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref())
&& counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1)
{
*value = None;
}
}
}
Some(bindings)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
Box { axis: u8, high: bool },
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
}
impl FaceCertificate {
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
if let Self::ExtrudeSide { sketch, .. } = self
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
&& let Some(mapped) = features.get(sketch)
{
*sketch = *mapped;
@@ -20,6 +166,8 @@ impl FaceCertificate {
}
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::Box { axis, .. } => *axis <= 2,
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
Self::Imported { payload_sha256, item, face } => {
*item != 0
@@ -39,6 +187,19 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
Some("box") => {
fields.len() == 3
&& fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str()))
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("extrude_cap") => {
fields.len() == 5
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
&& nonzero("sketch")
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
&& valid_signature_json(fields.get("outer"), fields.get("holes"))
}
Some("extrude_side") => {
fields.len() == 3
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
@@ -143,7 +304,9 @@ impl Document {
.ok_or_else(|| DocError::Unknown("face source name missing".into()))?;
let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) => *producer,
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
_ => name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
@@ -178,17 +341,36 @@ impl Document {
return Ok(Some(certificate));
}
let certificate = match source {
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => {
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) {
return Err(DocError::Invalid("side source producer missing".into()));
}
FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }
}
_ => return Err(DocError::Invalid("face source does not match producer".into())),
_ => return Ok(None),
};
certificate.validate()?;
Ok(Some(certificate))
}
pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
let mut target = self.configuration_face_target(body, index, point)?;
match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => {
target.origin_feature = *producer;
let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?;
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
}
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() });
}
_ => {}
}
self.certify_config_face(&target)?;
Ok(target)
}
}
pub(crate) fn payload_digest(payload: &str) -> String {