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

+21
View File
@@ -0,0 +1,21 @@
//! Presentation/projection state for an exact selected planar source placement.
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
use std::borrow::Cow;
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
let frame = anchor.author_frame(doc, author)?;
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
let relative = anchor.source_to_author(doc, author, raw)?;
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? };
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
body.component = raw.component;
body.transferred = raw.transferred;
body.source_name = raw.source_name.clone();
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
body.imported = raw.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
*slot = body;
Ok(Cow::Owned(output))
}
+29 -14
View File
@@ -146,10 +146,10 @@ fn remap_geo(
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
depth: usize,
) {
) -> crate::Result<()> {
use crate::construct::GeoRef;
if depth > 64 {
return;
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match g {
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
@@ -159,25 +159,40 @@ fn remap_geo(
}
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1),
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
_ => {}
}
Ok(())
}
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>, bodies: &BTreeMap<String, String>, depth: usize) {
fn remap_plane(
p: &mut PlaneRef,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
depth: usize,
) -> crate::Result<()> {
if depth > 64 {
return;
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
PlaneRef::Construction { name } => mapped_name(name, names),
PlaneRef::Face { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
PlaneRef::Face { name, anchor, .. } => {
if let Some(name) = name {
*name = remap_topology(name, ids, bodies);
}
if let Some(anchor) = anchor {
anchor.remap_target(ids, bodies)?;
}
}
_ => {
for r in p.refs_mut() {
remap_geo(r, ids, names, bodies, depth + 1);
remap_geo(r, ids, names, bodies, depth + 1)?;
}
}
}
Ok(())
}
fn remap_link(
@@ -348,7 +363,7 @@ impl crate::Feature {
}
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, names, bodies, 0);
remap_plane(plane, ids, names, bodies, 0)?;
for c in &mut sketch.constraints {
if let Some(n) = &mut c.param {
mapped_name(n, parameters);
@@ -365,17 +380,17 @@ impl crate::Feature {
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0),
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0),
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0),
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0);
remap_geo(r, ids, names, bodies, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0);
remap_geo(r, ids, names, bodies, 0)?;
}
}
_ => {}
+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 {
+48 -1
View File
@@ -109,6 +109,13 @@ impl Document {
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
@@ -149,9 +156,49 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
let indices: Vec<_> = body
.body
.face_sources()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, source)| {
let matched = match (&target.certificate, source) {
(
Some(super::FaceCertificate::Box { axis, high }),
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
(
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap {
producer,
sketch: current_sketch,
high: current_high,
outer: current_outer,
holes: current_holes,
..
}),
) => {
*producer == target.origin_feature
&& sketch == current_sketch
&& high == current_high
&& outer == current_outer
&& holes == current_holes
}
_ => false,
};
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
}).collect();
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
+20 -4
View File
@@ -56,15 +56,18 @@ pub enum PlaneRef {
AlongPath { path: Box<GeoRef>, t: String },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
/// A planar body face: found again on each evaluation (the face with this plane's normal
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
/// persistent name (found first; see `naming`).
/// A planar body face. Certified attachments resolve only their exact source and report
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
/// the picked `plane` as their historical fallback.
Face {
plane: Plane,
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Exact attachment identity for newly certified faces; absent on legacy designs.
#[serde(default, skip_serializing_if = "Option::is_none")]
anchor: Option<Box<crate::FaceAnchor>>,
},
}
@@ -116,6 +119,16 @@ pub enum Direction {
Symmetric,
}
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
/// along the same sketch-normal sweep direction as the kernel.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevolveConvention {
#[default]
LegacyAxisStart,
SketchNormal,
}
/// Extent of an extrude.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Extent {
@@ -282,6 +295,9 @@ pub enum FeatureKind {
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// Missing fields retain the angular placement of existing designs.
#[serde(default)]
convention: RevolveConvention,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
+163 -31
View File
@@ -529,7 +529,10 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
return hash.finish();
}
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
let anchor = crate::face_anchor::plane_face_anchor(plane);
if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty()))
|| (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty())
{
return 0;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1494,17 +1497,53 @@ pub fn revolve_extent(
(angle, angle2): (f64, Option<f64>),
symmetric: bool,
to: Option<Vec3>,
) -> Result<RevolveExtent> {
revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart)
}
#[allow(clippy::too_many_arguments)]
pub fn revolve_extent_with_convention(
st: &ModelState,
plane: &Plane,
regions: &[Region2],
(origin, axis): (Vec3, Vec3),
(angle, angle2): (f64, Option<f64>),
symmetric: bool,
to: Option<Vec3>,
convention: crate::RevolveConvention,
) -> Result<RevolveExtent> {
let (start, sweep) = match (to, angle2, symmetric) {
(Some(point), _, _) => {
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
let centre = if convention == crate::RevolveConvention::SketchNormal {
regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))?
} else {
plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64))
};
let projection_axis = if convention == crate::RevolveConvention::SketchNormal {
axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?
} else {
axis
};
let flat = |point: Vec3| {
let v = point - origin;
v - axis * v.dot(axis)
v - projection_axis * v.dot(projection_axis)
};
let (u, v) = (flat(centre), flat(point));
let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v));
let angle_axis = if convention == crate::RevolveConvention::SketchNormal {
if v.len() < 1e-9 {
return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into()));
}
kernel::canonical_revolve_axis(
plane,
regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?,
plane.to_local(origin),
Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)),
)?
} else {
-axis
};
let sweep = angle_axis.dot(u.cross(v)).atan2(u.dot(v));
(0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep })
}
(None, Some(other), _) => (-other, angle + other),
@@ -1519,8 +1558,22 @@ pub fn revolve_extent(
let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre);
let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis);
let rotation_axis = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis };
let display_start = start * -unit_axis.dot(rotation_axis);
let rotation_axis = match (convention, regions.first()) {
(crate::RevolveConvention::SketchNormal, Some(region)) => {
kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))?
}
_ => {
if unit_axis.cross(radial).dot(plane.normal()) < 0.0 {
-unit_axis
} else {
unit_axis
}
}
};
let display_start = match convention {
crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis),
crate::RevolveConvention::SketchNormal => start,
};
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
}
@@ -1629,25 +1682,33 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
if *operation == Operation::NewBody && extent.taper.is_none() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
tools = tools
.into_iter()
.map(|body| {
.enumerate()
.map(|(body_index, body)| {
let curves: Vec<_> = body
.face_sources()
.into_iter()
.flatten()
.map(|source| match source {
Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment),
Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => {
crate::config::exact_sketch_curve(&ss, segment)
}
_ => None,
})
.collect();
body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from)
let caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
body.with_extrude_certificates(f.id, *sketch, &curves)?
.with_extrude_cap_certificates(f.id, *sketch, &caps)
.map_err(DocError::from)
})
.collect::<Result<Vec<_>>>()?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis)?;
@@ -1656,26 +1717,66 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
} else {
(val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?)
};
let extent = revolve_extent(st, &ss.plane, &regions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?;
let extent = revolve_extent_with_convention(
st,
&ss.plane,
&regions,
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
*convention,
)?;
let (start, ang) = (extent.start, extent.sweep);
let region_extents = regions
.iter()
.map(|r| {
if *convention == crate::RevolveConvention::SketchNormal && to.is_some() {
revolve_extent_with_convention(
st,
&ss.plane,
std::slice::from_ref(r),
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
*convention,
)
} else {
Ok(extent)
}
})
.collect::<Result<Vec<_>>>()?;
let (operation, targets) = feature_op(f);
let axis_w = ss.plane.dir_to_world(d);
let origin_w = ss.plane.to_world(o);
let probe = |p: Vec2| {
let q = ss.plane.to_world(p);
(1..4)
.map(|k| {
let a = ang * k as f64 / 4.0;
let v = q - origin_w;
origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos()))
})
.collect::<Vec<_>>()
};
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
let regions: Vec<Region2> = regions
.iter()
.zip(&region_extents)
.map(|(r, region_extent)| {
let rotation_axis = match convention {
crate::RevolveConvention::LegacyAxisStart => axis_w,
crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?,
};
let probe = |p: Vec2| {
let q = ss.plane.to_world(p);
(1..4)
.map(|k| {
let a = region_extent.sweep * k as f64 / 4.0
+ if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 };
let v = q - origin_w;
origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos()))
})
.collect::<Vec<_>>()
};
Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe))
})
.collect::<Result<Vec<_>>>()?;
// To a curved face: turn past the whole target body, then cut back to its surface
// (the piece that starts at the profile).
let curved = to.and_then(|p| curved_target_any(st, p));
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let curved_body = curved.and_then(|bi| st.bodies.get(bi));
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Body>> {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
@@ -1685,15 +1786,36 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
let u0 = flat(c);
let angle_of = |q: Vec3| {
let v = flat(q);
let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v));
let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v));
if a < 0.0 { a + std::f64::consts::TAU } else { a }
};
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
let bodies = kernel::revolve(&ss.plane, &regions, o, d, sweep)?;
let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?;
let n = ss.plane.normal();
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
return trim_to_body(bodies, &tb.body, tol_side);
trim_to_body(bodies, &tb.body, tol_side)
};
if *convention == crate::RevolveConvention::LegacyAxisStart
&& let Some(tb) = curved_body
{
return build_curved(&regions, -axis_w, tb);
}
if *convention == crate::RevolveConvention::SketchNormal {
let mut out = Vec::new();
for (region, region_extent) in regions.iter().zip(&region_extents) {
let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?;
if let Some(tb) = curved_body {
out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?);
continue;
}
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
for body in bodies {
out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? });
}
}
return Ok(out);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
@@ -2565,13 +2687,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
FeatureKind::Sketch { plane, sketch } => {
let fst = state_in_frame(doc, st, f.component);
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
None => state_in_frame(doc, st, f.component),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
@@ -2590,6 +2720,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
let profiles = find_profiles(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
@@ -2953,6 +3084,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
+561
View File
@@ -0,0 +1,561 @@
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
use crate::config::ConfigFaceTarget;
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_geom::Plane;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceAnchor {
pub target: ConfigFaceTarget,
pub component: u64,
pub path: Vec<u64>,
pub author_path: Vec<u64>,
/// Origin and x direction in the exact canonical face plane's coordinates.
pub coordinates: [f64; 2],
pub x_axis: [f64; 2],
}
impl FaceAnchor {
pub fn remap_target(
&mut self,
features: &std::collections::BTreeMap<u64, u64>,
bodies: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) {
return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into()));
}
self.target.name = crate::remap_topology(&self.target.name, features, bodies);
if let Some(body) = bodies.get(&self.target.body) {
self.target.body = body.clone();
}
if let Some(feature) = features.get(&self.target.origin_feature) {
self.target.origin_feature = *feature;
}
if let Some(certificate) = &mut self.target.certificate {
certificate.remap_features(features);
}
self.validate()
}
pub fn validate(&self) -> Result<()> {
self.target.validate()?;
if self.target.certificate.is_none()
|| self.path.len() > 64
|| self.author_path.len() > 64
|| self.path.iter().chain(&self.author_path).any(|id| *id == 0)
|| (self.component == 0 && !self.path.is_empty())
|| (self.component != 0 && self.path.is_empty())
|| self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9)
|| self.x_axis.iter().any(|value| !value.is_finite())
|| (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6
{
return Err(DocError::Invalid("invalid exact face attachment".into()));
}
Ok(())
}
pub fn author_frame(&self, doc: &Document, author: u64) -> Result<crate::Mat> {
if author != 0 && self.author_path.is_empty() {
return Err(DocError::Invalid(
"attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(),
));
}
EmbossFace::component_frame(doc, author, &self.author_path)
}
pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result<crate::Mat> {
self.validate()?;
if doc.model_body_component(body) != self.component {
return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into()));
}
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
let source =
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
}
}
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
match plane {
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
_ => None,
}
}
fn has_face_reference(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Face { .. } => true,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
_ => false,
}
}
impl Document {
pub fn resolve_attached_plane(
&self,
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
plane: &PlaneRef,
author: u64,
state: &ModelState,
framed: &ModelState,
) -> Result<Plane> {
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
if depth > 64 {
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
}
match plane {
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
_ => {}
}
Ok(())
}
let mut resolved = plane.clone();
replace(self, &mut resolved, author, state, 0)?;
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
}
}
/// All status planes are world-space. A lost plane is a repair guide only.
#[derive(Clone, Debug)]
pub enum FaceAttachmentStatus {
Fixed { plane: Option<Plane> },
Legacy { plane: Option<Plane> },
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
Lost { message: String, repair_plane: Option<Plane> },
}
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
}
impl Document {
/// Capture a known selected face in the definition model, without another nearest pick.
pub fn capture_face_anchor(
&self,
body: &ModelBody,
index: usize,
picked: &Plane,
target_path: &[u64],
author: u64,
author_path: &[u64],
) -> Result<FaceAnchor> {
let source = body
.body
.face_plane(index)
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
self.configuration_face_indices(body, &target)?;
let mut anchor = FaceAnchor {
target,
component: self.model_body_component(body),
path: target_path.to_vec(),
author_path: author_path.to_vec(),
coordinates: [0., 0.],
x_axis: [1., 0.],
};
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?;
if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("picked plane does not match its exact source face".into()));
}
let delta = picked.origin - canonical.origin;
anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)];
anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)];
anchor.validate()?;
Ok(anchor)
}
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
anchor.validate()?;
let body =
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
let indices = self.configuration_face_indices(body, &anchor.target)?;
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
for index in indices.iter().skip(1) {
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
}
}
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
}
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
let Some(feature) = self.feature(feature) else {
return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None };
};
let FeatureKind::Sketch { plane, .. } = &feature.kind else {
return FaceAttachmentStatus::Fixed { plane: None };
};
let Some(anchor) = plane_face_anchor(plane) else {
let path = self.first_path(feature.component);
let unique = path.iter().all(|id| {
self.occurrences
.iter()
.find(|o| o.id == *id)
.is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1)
});
let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten();
let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| {
let (vals, _) = self.param_values();
self.resolve_plane(&vals, plane, 0).ok()
});
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
return if has_face_reference(plane) {
FaceAttachmentStatus::Legacy { plane: current }
} else {
FaceAttachmentStatus::Fixed { plane: current }
};
};
let (vals, _) = self.param_values();
let picked = self.resolve_plane(&vals, plane, 0).ok();
let world = anchor.author_frame(self, feature.component);
let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok()));
let state = model.state_before(feature.id);
match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component)
.and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed))
.and_then(|plane| map_plane(&world?, &plane))
{
Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() },
Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane },
}
}
}
pub fn validate_face_anchor_json(value: &Value) -> Result<()> {
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?;
if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str()))
{
return Err(DocError::Invalid("unknown or missing face anchor fields".into()));
}
let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?;
crate::config::validate_face_target_json(target)?;
if target.get("certificate").is_none_or(Value::is_null) {
return Err(DocError::Invalid("face anchor needs a source certificate".into()));
}
let component =
fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?;
for key in ["path", "author_path"] {
if fields.get(key).and_then(Value::as_array).is_none_or(|path| {
path.len() > 64
|| path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0))
|| (key == "path" && component == 0 && !path.is_empty())
|| (key == "path" && component != 0 && path.is_empty())
}) {
return Err(DocError::Invalid("face anchor path is invalid or oversized".into()));
}
}
for key in ["coordinates", "x_axis"] {
let values = fields
.get(key)
.and_then(Value::as_array)
.filter(|values| values.len() == 2)
.ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?;
if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) {
return Err(DocError::Invalid("face anchor vector is invalid".into()));
}
if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 {
return Err(DocError::Invalid("face anchor basis must be normalized".into()));
}
}
Ok(())
}
/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads.
pub fn validate_face_anchors_json(value: &Value) -> Result<()> {
let mut stack = vec![(value, 0usize)];
let mut visited = 0usize;
while let Some((value, depth)) = stack.pop() {
visited += 1;
if visited > 2_000_000 || depth > 128 {
return Err(DocError::Invalid("face anchor structure exceeds work limits".into()));
}
match value {
Value::Object(fields) => {
if fields.get("type").and_then(Value::as_str) == Some("face")
&& let Some(anchor) = fields.get("anchor")
&& !anchor.is_null()
{
validate_face_anchor_json(anchor)?;
}
for (key, child) in fields {
if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str())
&& matches!(child, Value::Array(_) | Value::Object(_))
{
stack.push((child, depth + 1));
}
}
}
Value::Array(values) => {
for child in values {
if matches!(child, Value::Array(_) | Value::Object(_)) {
stack.push((child, depth + 1));
}
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_geom::Vec3;
fn fixture() -> (Document, ModelBody, usize, Plane) {
let mut doc = Document::default();
let kind: FeatureKind =
serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap();
let id = doc.add_feature(kind, Some("Base")).unwrap();
let body = ModelBody::new(
"Base".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(),
id,
);
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
.unwrap();
let canonical = body.body.face_plane(index).unwrap();
let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap();
(doc, body, index, picked)
}
#[test]
fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() {
let (doc, body, index, picked) = fixture();
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap();
assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement");
let mut missing_source = anchor.clone();
missing_source.component = 1;
assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path");
assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err());
let mut state = ModelState::default();
state.bodies.push(body);
let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
assert!((initial.origin - picked.origin).len() < 1e-9);
assert!((initial.x - picked.x).len() < 1e-9);
state.bodies[0] = ModelBody::new(
"Base".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature)
.unwrap(),
anchor.target.origin_feature,
);
let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
assert!((changed.origin.z - 20.).abs() < 1e-9);
assert!((changed.origin.x - initial.origin.x).abs() < 1e-9);
state.bodies[0] = ModelBody::new(
"Control".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature)
.unwrap(),
anchor.target.origin_feature,
);
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err());
state.bodies[0].name = "Base".into();
state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature + 1)
.unwrap();
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer");
}
#[test]
fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() {
let (doc, body, index, picked) = fixture();
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
let plane = PlaneRef::Offset {
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }),
distance: "2".into(),
};
let mut state = ModelState::default();
state.bodies.push(body);
let (vals, _) = doc.param_values();
let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap();
assert!((result.origin.z - 12.).abs() < 1e-9);
state.bodies.clear();
assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err());
let mut raw = serde_json::to_value(anchor).unwrap();
assert!(validate_face_anchor_json(&raw).is_ok());
raw["path"] = serde_json::json!([1]);
assert!(validate_face_anchor_json(&raw).is_err());
raw["path"] = serde_json::json!([]);
raw["target"]["name"] = Value::String("x".repeat(1_000_000));
assert!(validate_face_anchor_json(&raw).is_err());
}
#[test]
fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() {
use solvecraft_geom::Vec2;
let mut sketch = solvecraft_sketch::Sketch::new();
for offset in [0., 30.] {
let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)]
.iter()
.map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap())
.collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
}
let mut doc = Document::default();
let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap();
let producer = doc
.add_feature(
FeatureKind::Extrude {
sketch: source,
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
extent,
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
assert_eq!(state.bodies.len(), 2);
let solved = state.sketch(source).unwrap();
let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap();
let raw = solvecraft_kernel::extrude(&solved.plane, &regions, 0., 10.).unwrap();
assert!(
crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(),
"exhausted certification work must leave the complete producer uncertified"
);
let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap();
assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget");
let mut duplicate = raw.clone();
duplicate.extend(raw);
let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap();
assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps");
let body = &state.bodies[0];
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. })))
.unwrap();
let picked = body.body.face_plane(index).unwrap();
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
let bounds = body.mesh().bounds();
let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else {
panic!("fixture must retain its extrude feature")
};
*profiles = crate::ProfileSel::Indices { indices: vec![1, 0] };
model.evaluate(&doc);
let changed = model.state();
assert_eq!(changed.bodies.len(), 2);
if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() {
let current = changed.body(&anchor.target.body).unwrap().mesh().bounds();
assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source");
}
}
#[test]
fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() {
let (mut doc, _, _, picked) = fixture();
let id = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Offset {
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }),
distance: "2".into(),
},
sketch: solvecraft_sketch::Sketch::new(),
},
None,
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else {
panic!("wrapped legacy attachment must retain its classification and fixed-plane escape");
};
assert!((plane.origin.z - 12.).abs() < 1e-9);
let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") };
assert!(plane_face_anchor(plane).is_none());
}
#[test]
fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() {
use solvecraft_geom::Vec2;
let (mut doc, _, _, _) = fixture();
let mut warm = Model::new();
warm.evaluate(&doc);
let source = warm.state();
let body = &source.bodies[0];
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
.unwrap();
let picked = body.body.face_plane(index).unwrap();
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
let source_name = body.name.clone();
let mut sketch = solvecraft_sketch::Sketch::new();
let points: Vec<_> =
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
let sk = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) },
sketch,
},
None,
)
.unwrap();
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
Some("Boss"),
)
.unwrap();
warm.evaluate(&doc);
let initial = warm.state();
let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
let mut offset = crate::IDENTITY;
offset[3][0] = 5.;
offset[3][2] = 3.;
doc.body_offsets.insert(source_name, offset);
warm.evaluate(&doc);
let mut cold = Model::new();
cold.evaluate(&doc);
let warm_state = warm.state();
let cold_state = cold.state();
let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!(
(after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7,
"before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}",
warm_state.sketch(sk).map(|sk| sk.plane),
cold_state.sketch(sk).map(|sk| sk.plane)
);
assert!((after.min - rebuilt.min).len() < 1e-7);
assert!((after.max - rebuilt.max).len() < 1e-7);
let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else {
panic!("offset source must remain attached");
};
assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
}
}
+214 -3
View File
@@ -31,13 +31,15 @@
//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face
//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine
//! 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.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 10;
pub const FORMAT: u32 = 11;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -50,7 +52,7 @@ 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; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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,
@@ -143,6 +145,8 @@ pub fn read(s: &str) -> Result<Document> {
check_numbers(&v, 0)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
let legacy_wires = validate_wires(&v, version)?;
@@ -184,6 +188,7 @@ pub fn read(s: &str) -> Result<Document> {
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
}
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
@@ -351,6 +356,58 @@ fn validate_emboss_targets(value: &Value) -> Result<()> {
Ok(())
}
/// Bound explicitly versioned geometry before allocating typed spline point arrays.
fn validate_versioned_geometry(value: &Value) -> Result<()> {
let invalid = || DocError::Invalid("damaged design: invalid periodic spline or revolve convention".into());
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
for feature in features {
if feature.get("type").and_then(Value::as_str) == Some("revolve")
&& let Some(convention) = feature.get("convention")
&& !matches!(convention.as_str(), Some("legacy_axis_start" | "sketch_normal"))
{
return Err(invalid());
}
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(sketch) = feature.get("sketch") else { continue };
if let Some(links) = sketch.get("links").and_then(Value::as_array) {
for link in links {
if link.get("auto_face").is_some_and(|marker| !marker.is_boolean()) {
return Err(invalid());
}
}
}
let Some(curves) = sketch.get("curves").and_then(Value::as_array) else { continue };
let points = sketch.get("points").and_then(Value::as_array);
let mut work = 0usize;
for curve in curves {
if curve.get("type").and_then(Value::as_str) != Some("spline") {
continue;
}
let Some(marker) = curve.get("periodic") else { continue };
let periodic = marker.as_bool().ok_or_else(invalid)?;
if !periodic {
continue;
}
let indices = curve.get("pts").and_then(Value::as_array).ok_or_else(invalid)?;
work = work.saturating_add(indices.len());
if !(4..=solvecraft_sketch::MAX_SPLINE_POINTS).contains(&indices.len())
|| work > 1_000_000
|| curve.get("control").is_some_and(|control| control.as_bool() != Some(false))
|| curve.get("degree").is_some_and(|degree| degree.as_u64() != Some(3))
{
return Err(invalid());
}
let count = points.ok_or_else(invalid)?.len();
if indices.iter().any(|index| index.as_u64().is_none_or(|index| index >= count as u64)) {
return Err(invalid());
}
}
}
Ok(())
}
fn validate_freeform_offsets(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
for feature in features {
@@ -363,6 +420,27 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
if records.len() > 128 {
return Err(invalid());
}
let periodic_outputs: std::collections::BTreeSet<_> = records
.iter()
.filter(|record| record.get("certificate").and_then(Value::as_u64) == Some(2))
.filter_map(|record| record.get("output").and_then(Value::as_str))
.collect();
let mut periodic_curves = std::collections::BTreeMap::new();
if !periodic_outputs.is_empty()
&& let Some(curves) = feature.get("sketch").and_then(|sketch| sketch.get("curves")).and_then(Value::as_array)
{
if curves.len() > 1_000_000 {
return Err(invalid());
}
for curve in curves {
if let Some(id) = curve.get("id").and_then(Value::as_str)
&& periodic_outputs.contains(id)
&& periodic_curves.insert(id, curve).is_some()
{
return Err(invalid());
}
}
}
let mut outputs = std::collections::BTreeSet::new();
for record in records {
let object = record.as_object().ok_or_else(invalid)?;
@@ -396,12 +474,22 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|| !(1e-9..=MAX_REAL).contains(&distance.abs())
|| !tolerance.is_finite()
|| !(tolerance > 0.0 && tolerance <= 0.002)
|| record.get("certificate").and_then(Value::as_u64) != Some(1)
|| !matches!(record.get("certificate").and_then(Value::as_u64), Some(1 | 2))
|| record.get("forward").and_then(Value::as_bool).is_none()
|| record.get("closed").and_then(Value::as_bool).is_none()
{
return Err(invalid());
}
if record.get("certificate").and_then(Value::as_u64) == Some(2) {
let output_curve = periodic_curves.get(output);
if record.get("closed").and_then(Value::as_bool) != Some(true)
|| output_curve.is_none_or(|curve| {
curve.get("type").and_then(Value::as_str) != Some("spline") || curve.get("periodic").and_then(Value::as_bool) != Some(true)
})
{
return Err(invalid());
}
}
}
let mut resolved = std::collections::BTreeSet::new();
for record in records {
@@ -673,6 +761,129 @@ mod tests {
assert!(error.contains("damaged design"), "{error}");
}
#[test]
fn malformed_face_anchors_are_refused_before_typed_decoding() {
let anchor = serde_json::json!({
"target":{"body":"Base", "origin_feature":1, "name":"Base:+z", "point":[0,0,10],
"certificate":{"kind":"box", "axis":2, "high":true}},
"component":0, "path":[], "author_path":[], "coordinates":[0,0], "x_axis":[1,0]
});
let make = |anchor: Value| {
serde_json::json!({"features":[{"type":"sketch", "plane":{
"type":"face", "anchor":anchor
}}]})
};
assert!(crate::face_anchor::validate_face_anchors_json(&make(anchor.clone())).is_ok());
let mut nested = serde_json::json!({"features":[{"type":"sketch", "plane":{
"type":"offset", "base":{"type":"at_angle", "base":{"type":"face", "anchor":anchor.clone()}}
}}]});
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_ok());
nested["features"][0]["plane"]["base"]["base"]["anchor"]["coordinates"] = serde_json::json!([0, 0, 0]);
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_err());
let mut malformed = Vec::new();
for (key, damaged) in [
("component", serde_json::json!(-1)),
("path", serde_json::json!([0])),
("author_path", serde_json::json!((1..=65).collect::<Vec<u64>>())),
("coordinates", serde_json::json!([1_000_000_001_u64, 0])),
("coordinates", serde_json::json!([0])),
("x_axis", serde_json::json!([0, 0])),
("unknown", serde_json::json!(true)),
] {
let mut candidate = anchor.clone();
candidate[key] = damaged;
malformed.push(candidate);
}
let mut missing_certificate = anchor.clone();
missing_certificate["target"].as_object_mut().unwrap().remove("certificate");
malformed.push(missing_certificate);
let mut invalid_role = anchor;
invalid_role["target"]["certificate"]["axis"] = Value::from(3);
malformed.push(invalid_role);
for candidate in malformed {
let value = make(candidate);
assert!(crate::face_anchor::validate_face_anchors_json(&value).is_err());
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(!error.contains("design file:"), "must refuse before typed decode: {error}");
}
}
#[test]
fn periodic_markers_are_bounded_before_typed_decoding() {
let curve = serde_json::json!({"id":"closed", "type":"spline", "pts":[0,1,2,3], "periodic":true});
let make = |curve: Value| {
serde_json::json!({"features":[{"type":"sketch", "sketch":{
"points":[{}, {}, {}, {}], "curves":[curve]
}}]})
};
assert!(validate_versioned_geometry(&make(curve.clone())).is_ok());
for (key, malformed) in [
("periodic", serde_json::json!("true")),
("control", serde_json::json!(true)),
("degree", serde_json::json!(2)),
("pts", serde_json::json!([0, 1, 2])),
("pts", serde_json::json!([0, 1, 2, u64::MAX])),
("pts", serde_json::json!(vec![0; solvecraft_sketch::MAX_SPLINE_POINTS + 1])),
] {
let mut damaged = curve.clone();
damaged[key] = malformed;
assert!(validate_versioned_geometry(&make(damaged)).is_err(), "{key}");
}
let mut legacy = curve;
legacy.as_object_mut().unwrap().remove("periodic");
legacy["control"] = Value::Bool(true);
assert!(validate_versioned_geometry(&make(legacy)).is_ok());
for convention in [serde_json::json!(null), serde_json::json!(false), serde_json::json!("unknown")] {
assert!(validate_versioned_geometry(&serde_json::json!({"features":[{"type":"revolve", "convention":convention}]})).is_err());
}
}
#[test]
fn periodic_offset_certificate_requires_closed_periodic_output() {
let record = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
"distance":2.0, "divisions":8, "pieces":4, "closed":true,
"tolerance":0.002, "certificate":2});
let mut value = serde_json::json!({"features":[{"type":"sketch", "sketch":{
"freeform_offsets":[record], "curves":[{"id":"offset", "type":"spline", "periodic":true}]
}}]});
assert!(validate_freeform_offsets(&value).is_ok());
let duplicate = value["features"][0]["sketch"]["curves"][0].clone();
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().push(duplicate);
assert!(validate_freeform_offsets(&value).is_err(), "periodic output ID must be unique");
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().pop();
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(false);
assert!(validate_freeform_offsets(&value).is_err());
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(true);
value["features"][0]["sketch"]["freeform_offsets"][0]["closed"] = Value::Bool(false);
assert!(validate_freeform_offsets(&value).is_err());
value["features"][0]["sketch"]["freeform_offsets"][0]["certificate"] = Value::from(1);
assert!(validate_freeform_offsets(&value).is_ok(), "legacy certificate semantics remain valid");
}
#[test]
fn periodic_file_validation_refuses_damaged_closed_geometry() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{CurveKind, Sketch};
let mut sketch = Sketch::new();
let mut pts = Vec::new();
for point in [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), Vec2::ZERO] {
pts.push(sketch.add_point(point, None).unwrap());
}
let first = pts[0];
let neighbor = pts[1];
let closing = pts[3];
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
let mut document = Document::new("periodic file validation");
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
assert_eq!(read(&document.to_json()).unwrap(), document);
let mut value: Value = serde_json::from_str(&document.to_json()).unwrap();
value["features"][0]["sketch"]["points"][closing]["pos"] = serde_json::json!([1, 0]);
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
value["features"][0]["sketch"]["points"][closing]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
value["features"][0]["sketch"]["points"][neighbor]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
}
#[test]
fn freeform_offset_metadata_is_bounded_before_typed_decoding() {
let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
+9 -1
View File
@@ -18,6 +18,8 @@ pub mod construct;
mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
pub mod joints;
pub mod motion;
@@ -33,7 +35,8 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height,
parse_metric_thread, pattern_matrices, revolve_extent, select_profiles, state_in_frame, state_in_frame_at, through_all_distance, world_state,
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, state_in_frame_at,
through_all_distance, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
@@ -55,5 +58,10 @@ pub enum DocError {
pub type Result<T> = std::result::Result<T, DocError>;
#[cfg(test)]
mod named_projection_tests;
#[cfg(test)]
mod tests;
mod anchor_frame;
pub use anchor_frame::state_in_face_anchor_frame;
+50
View File
@@ -0,0 +1,50 @@
use crate::{Document, ModelBody, ModelState};
use solvecraft_geom::{Plane, Vec3};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
use std::collections::BTreeMap;
use std::sync::Arc;
fn named_box(name: &str, width: f64, height: f64) -> ModelBody {
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap();
let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()];
let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index;
names[top] = "shared:top".into();
let mut result = ModelBody::new(name.into(), body, 1);
result.names = crate::naming::NameCell::known(Arc::new(names));
result
}
#[test]
fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
let doc = Document::default();
let mut initial = ModelState::default();
initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]);
let plane = Plane::XY.offset(20.);
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) };
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap();
let mut sketch = Sketch::default();
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
sketch.links[0].face_name = Some("shared:top".into());
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
let mut current = ModelState::default();
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), &current, &plane, &mut sketch).is_empty());
assert!(!sketch.links[0].lost);
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6));
// The closest named point can sit on the wall/top seam: retain its actual
// face index rather than re-picking the adjacent vertical wall.
let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0;
assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99);
let saved = sketch.clone();
let mut missing = ModelState::default();
missing.bodies.push(named_box("Control", 5., 10.));
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
assert!(sketch.links[0].lost);
assert!(!warnings.is_empty());
assert_eq!(sketch.curves, saved.curves);
assert_eq!(sketch.points, saved.points);
assert_eq!(sketch.links[0].id, link);
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
}
+43
View File
@@ -655,6 +655,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
.collect()
}
/// Capture a face name from the recorded projection body only.
pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
if body.body.is_mesh() || !point.is_finite() {
return None;
}
let mesh = body.mesh();
let count = face_count(body);
if mesh.triangles.len() > 2_000_000 || count > 50_000 {
return None;
}
let tolerance = (body.body.size() * 1e-3).max(1e-3);
let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?;
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "it was split"); `None` when no face has the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
@@ -679,3 +694,31 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool
}
best.map(|(_, q, split)| (q, split))
}
/// Resolve a legacy named face only on its recorded source body. Return the
/// selected face index as well as the closest point, which may lie on a shared
/// edge and therefore must not be used to pick the face a second time.
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
if body.body.is_mesh() {
return None;
}
let names = face_names(body);
let mesh = body.mesh();
let stem = strip_piece(name);
let mut best: Option<(f64, usize, Vec3, bool)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let index = usize::try_from(*face).ok()?;
let Some(candidate) = names.get(index) else { continue };
let exact = candidate == name;
if !exact && strip_piece(candidate) != stem {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let q = closest(p, a, b, c);
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
if best.is_none_or(|(old, _, _, _)| distance < old) {
best = Some((distance, index, q, !exact));
}
}
best.map(|(_, index, q, split)| (index, q, split))
}
+135 -7
View File
@@ -32,6 +32,9 @@ pub struct Resolved {
pub source: LinkSource,
/// The reference was not exactly where it was and was re-found on the nearest geometry.
pub moved: bool,
/// Exact face selected during this resolution, before its closest point is
/// projected onto an edge shared with another face. Computed metadata only.
pub face_index: Option<usize>,
}
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
@@ -94,6 +97,7 @@ fn resolve_uncached(
) -> Result<Resolved> {
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
let mut moved = false;
let mut face_index = None;
let mut source = src.clone();
let mut wires: Vec<Vec<Vec3>> = Vec::new();
// Included curves that leave the sketch plane stay 3D.
@@ -125,6 +129,7 @@ fn resolve_uncached(
LinkSource::Face { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?;
face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?);
let size = mesh.bounds().diagonal().max(1e-9);
if d > size * 0.25 {
return Err(lost("the face"));
@@ -267,19 +272,130 @@ fn resolve_uncached(
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
}
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved })
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
}
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
/// links; lost links keep their last geometry.
fn lost(reference: &str) -> DocError {
DocError::Unknown(format!("{reference} no longer exists"))
}
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let mesh = target.mesh();
if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 {
return Err(DocError::Invalid("named face projection work limit exceeded".into()));
}
let face_index = Some(index);
let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?;
let geom = if kind == LinkKind::Intersect {
section(plane, &mesh, Some(index))
} else {
let mut geom = Vec::new();
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points));
}
}
merge_split_circles(geom)
};
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
}
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
}
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
let indices = doc.configuration_face_indices(body, &anchor.target)?;
resolve_face_indices(st, plane, kind, &body.name, &indices)
}
/// Project explicitly selected current face indices, without claiming persistent provenance.
/// Callers retaining a link must supply their own replay identity policy.
pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result<Resolved> {
let body = st.body(body_name).ok_or_else(|| lost("projection body"))?;
let mesh = body.mesh();
if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 {
return Err(DocError::Invalid("anchored projection work limit exceeded".into()));
}
let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len());
if work > 10_000_000 {
return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into()));
}
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
let mut geometry = Vec::new();
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
if kind == LinkKind::Intersect {
geometry.extend(section(plane, &mesh, Some(index)));
} else {
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
}
}
}
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0))
.ok_or_else(|| lost("anchored projection face"))?;
if geometry.is_empty() {
return Err(lost("anchored projection boundaries"));
}
Ok(Resolved {
geom: merge_split_circles(geometry),
wires: Vec::new(),
source: LinkSource::Face { body: body.name.clone(), at: point },
moved: false,
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
})
}
pub fn refresh_face_anchor_links(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: &crate::FaceAnchor,
) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
}
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, None)
}
fn refresh_links_in(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
) -> Vec<String> {
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
let mut named_face = None;
if !l.auto_face
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
*at = p;
named_face = Some((body.clone(), index, p));
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
@@ -289,7 +405,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
let resolved = if l.auto_face {
anchor
.filter(|anchor| {
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
})
.ok_or_else(|| lost("exact auto-projected attachment"))
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
} else if let Some((body, index, at)) = named_face {
resolve_named_face(st, plane, l.kind, &body, index, at)
} else {
resolve(doc, vals, st, plane, l.kind, &source)
};
match resolved {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -442,9 +570,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
_ => Vec::new(),
},
Some(CurveKind::Spline { pts, control, degree }) if parallel => {
Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => {
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default()
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default()
}
Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) {
(Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }],
@@ -560,7 +688,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option<LinkGeom> {
<= tol
});
if ok {
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 });
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false });
}
}
None
+1 -1
View File
@@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
LinkGeom::Ellipse { c, major, minor } => {
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
}
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
LinkGeom::Point(_) => Vec::new(),
}
}
+1
View File
@@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() {
targets: vec![],
angle2: None,
symmetric: false,
convention: Default::default(),
to: None,
},
None,