feat: stabilize face attachments and closed spline workflows
This commit is contained in:
1 parent
c1d819f235
commit
a6bb8fc924
83 files changed
+6271
-385
No files matched your search
@@ -0,0 +1,215 @@
|
||||
//! 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 solvecraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
use solvecraft_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 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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = solvecraft_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(solvecraft_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<&solvecraft_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 solvecraft_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;
|
||||
Reference in new issue
Block a user