Files
OneCAD/crates/engine/src/cmd/sketch_face.rs
T
bherbruckandClaude Opus 5.5 45bc22fc30 fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:01:35 -04:00

252 lines
12 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(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: it was merged from more than one feature's faces or reshaped by an edit. \
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;