* Name a side drawn on several sketch curves by all of them An extrude named each side wall after the one sketch curve its profile segment lies on. A wall along two coincident lines (squares drawn edge to edge) or along collinear lines merged into one profile segment had no single curve, so it was named from geometry and could not carry a sketch attachment. One sketch extruded up and down and joined (the logo) refused a sketch on every one of its side walls. Such a side is now named by every curve it lies on, in sketch order: the first is its name, the others aliases. The set is fixed by the sketch, so the name is exact. The refusal message no longer blames merged faces. Adds the reported logo design and a synthetic up-and-down join to the action-matrix designs, and a regression test that sketches and extrudes on every flat face, fresh and after save and reopen. Fixes #954 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Action matrix: record the up-and-down join designs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
12 KiB
Rust
253 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 (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;
|