//! Exact planar face capture for sketch attachment. World labels are lookup keys only. use crate::params::{bad, vec3}; use crate::{Result, Session}; use serde_json::Value; use solidcraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul}; use solidcraft_geom::Plane; pub(in crate::cmd) fn is_face(p: &Value) -> bool { p.get("plane").and_then(|plane| plane.get("face")).is_some() } pub(in crate::cmd) fn capture(s: &Session, value: &Value, cmd: &str) -> Result { capture_impl(s, value, cmd, false) } /// Internal face-profile compatibility only; never exposed as a command parameter. pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> Result { capture_impl(s, value, cmd, true) } fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { let point = value .get("face") .and_then(vec3) .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) .ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?; let fixed = match value.get("fixed") { None => false, Some(Value::Bool(fixed)) => *fixed, _ => return Err(bad(cmd, "fixed must be a boolean")), }; let state = s.model.state(); let doc = s.placed_document(); let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?; let requested_body = match value.get("body") { None => None, Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()), _ => return Err(bad(cmd, "body must be the picked world body name")), }; let requested_index = match value.get("index") { None => None, Some(value) => { Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?) } }; if requested_body.is_some() != requested_index.is_some() { return Err(bad(cmd, "an exact face pick requires both body and index")); } let requested_path = match value.get("path") { None => None, Some(value) => Some( value .as_array() .filter(|path| path.len() <= 64) .ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))? .iter() .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers"))) .collect::>>()?, ), }; let selected_path = requested_path .clone() .or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body)))); let placements = if let Some(path) = selected_path { let component = path .last() .and_then(|id| doc.occurrences.iter().find(|occurrence| occurrence.id == *id)) .map(|occurrence| occurrence.component) .unwrap_or(0); let frame = EmbossFace::component_frame(&doc, component, &path)?; vec![(component, path, frame)] } else { crate::cmd::component::properties::placements(&doc, cmd)? }; let mut by_component: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for body in &state.bodies { by_component.entry(doc.model_body_component(body)).or_default().push(body); } let mut chosen = None; let mut sheet_face: Option<(String, Plane)> = None; let mut work = state.bodies.len().saturating_add(doc.occurrences.len()); for (component, path, placement) in placements { let Some(bodies) = by_component.get(&component) else { continue; }; for body in bodies { if let Some(source) = requested_body.and_then(|name| state.body(name)) && source.name != body.name { continue; } work = work.saturating_add(1); if let Some(requested) = requested_body { work = work.saturating_add(doc.occurrences.len()).saturating_add(state.bodies.len()).saturating_add(path.len()); if work > 2_000_000 { return Err(bad(cmd, "face capture work limit exceeded; supply exact path")); } if requested != doc.world_body_name(&state, &body.name, component, &path) { continue; } } let frame = doc.model_body_offset(body).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement); let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?; let local_point = apply_point(&inverse, point); let tolerance = (body.body.size() * 1e-4).max(1e-5); // A flat face of a sheet metal body attaches to its panel (or a panel's rim): the // exact plane comes from the panel's flat region and fold, and the sketch keeps the // face's name to follow it. if !fixed && let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name) && let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0) { if chosen.is_some() || sheet_face.is_some() { return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index")); } let relative = mat_mul(&author_inverse, &frame); let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y)) .ok_or_else(|| bad(cmd, "invalid sheet face plane"))?; sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane)); continue; } let faces = body.body.faces(tolerance)?; let mesh = body.body.tessellate(tolerance)?; work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() })); if work > 2_000_000 { return Err(bad(cmd, "face capture work limit exceeded; use an exact body and face index")); } for face in faces { if requested_index.is_some_and(|index| index != face.index) { continue; } let Some(exact_plane) = (if fixed { body.body.planar_face(face.index).or_else(|| body.body.face_plane(face.index)) } else { body.body.face_plane(face.index).or_else(|| body.body.planar_face(face.index)) }) else { continue; }; let normal = exact_plane.normal(); if (local_point - face.centroid).dot(normal).abs() > tolerance * 10.0 || !super::face_contains(&mesh, face.index, local_point, tolerance * 10.0) { continue; } if chosen.is_some() { return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index")); } let relative = mat_mul(&author_inverse, &frame); let normal = apply_vector(&relative, normal).normalized().ok_or_else(|| bad(cmd, "invalid planar face normal"))?; let centroid = apply_point(&relative, face.centroid); let picked = Plane::from_normal(normal * centroid.dot(normal), normal).ok_or_else(|| bad(cmd, "invalid selected planar face"))?; chosen = Some((body, face.index, path.clone(), picked)); } } } if let Some((name, plane)) = sheet_face { if chosen.is_some() { return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index")); } return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None }); } let (body, index, path, plane) = chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?; if fixed { return Ok(PlaneRef::Custom { plane }); } let at = apply_point(&author_inverse, point); let name = solidcraft_doc::naming::face_names(body).get(index).cloned(); let anchor = match doc.capture_face_anchor(body, index, &plane, &path, s.active_component, &author_path) { Ok(anchor) => Some(Box::new(anchor)), Err(solidcraft_doc::DocError::Invalid(message)) if internal && matches!( message.as_str(), "this face has no exact planar source; choose a fixed plane instead" | "this face has no supported construction certificate; choose a fixed plane instead" ) => { if path.iter().any(|id| s.pending_moves.contains_key(id) && !author_path.contains(id)) { return Err(bad(cmd, "capture the source occurrence position before generating a legacy face profile across components")); } None } Err(solidcraft_doc::DocError::Invalid(message)) if matches!( message.as_str(), "this face has no exact planar source; choose a fixed plane instead" | "this face has no supported construction certificate; choose a fixed plane instead" ) => { return Err(bad( cmd, "this face can't keep an attachment: its history can't tell it apart from other faces (an edit reshaped it, \ or more than one face could claim its name). \ Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face", )); } Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))), }; Ok(PlaneRef::Face { plane, at, name, anchor }) } /// The sole durable face attachment beneath an offset/angle wrapper. pub(in crate::cmd) fn anchor(mut plane: &PlaneRef) -> Option<&solidcraft_doc::FaceAnchor> { loop { match plane { PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, PlaneRef::Face { anchor, .. } => return anchor.as_deref(), _ => return None, } } } pub(in crate::cmd) fn anchor_mut(mut plane: &mut PlaneRef) -> Option<&mut solidcraft_doc::FaceAnchor> { loop { match plane { PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, PlaneRef::Face { anchor, .. } => return anchor.as_deref_mut(), _ => return None, } } } pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Result<()> { let mut involved = std::collections::BTreeSet::new(); if let Some(anchor) = anchor(plane) { involved.extend(anchor.path.iter().copied()); involved.extend(anchor.author_path.iter().copied()); } else { involved.extend(s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component))); } let changes: Vec<_> = involved.into_iter().filter_map(|id| s.pending_moves.get(&id).copied().map(|matrix| (id, matrix))).collect(); for (id, matrix) in &changes { let occurrence = s .doc_mut() .occurrences .iter_mut() .find(|occurrence| occurrence.id == *id) .ok_or_else(|| bad("sketch attachment", "pending author/source path disappeared"))?; occurrence.transform = *matrix; } for (id, _) in changes { s.pending_moves.remove(&id); } Ok(()) } #[cfg(test)] #[path = "sketch_face_tests.rs"] mod tests;