Files
OneCAD/crates/engine/src/cmd/sketch_face.rs
T
NOWN-DEVandClaude Opus 5.5 af0440aaab feat: sketches on a sheet metal face attach to its panel (#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.

`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:<body>:<panel>:<top|bottom>` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:36:58 -04:00

239 lines
11 KiB
Rust

//! 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<PlaneRef> {
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<PlaneRef> {
capture_impl(s, value, cmd, true)
}
fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result<PlaneRef> {
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::<Result<Vec<_>>>()?,
),
};
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<u64, Vec<_>> = 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: 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
&& !internal
&& 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(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;