//! 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, pub author_path: Vec, /// 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, bodies: &std::collections::BTreeMap, ) -> 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 { 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 { 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, plane: &PlaneRef, author: u64, state: &ModelState, framed: &ModelState, ) -> Result { 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 }, Legacy { plane: Option }, Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec }, Lost { message: String, repair_plane: Option }, } fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result { 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 { 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 { 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, ®ions, 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); } }