feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+95 -32
View File
@@ -11,6 +11,8 @@ use crate::{EngineError, Result, Session};
#[path = "sketch_dimensioned.rs"]
mod dimensioned;
#[path = "sketch_face.rs"]
pub(super) mod face;
use dimensioned::draw as draw_dimensioned;
/// The `connect` parameter every drawing command takes (see `connect_points`).
@@ -24,8 +26,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
.at("SKETCH", "CREATE")
@@ -232,6 +234,7 @@ pub(super) fn edit<T>(
if strict && !rep.ok() {
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
}
sk.validate_periodic_splines()?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
@@ -357,36 +360,56 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
// ---------------------------------------------------------------------------------------------
// Sketch lifecycle
pub(crate) fn author_path_for_rebind(s: &Session, feature: &solvecraft_doc::Feature, p: &Value, cmd: &str) -> Result<Vec<u64>> {
let mut saved = None;
if let FeatureKind::Sketch { plane, .. } = &feature.kind {
let mut plane = plane;
loop {
match plane {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
PlaneRef::Face { anchor: Some(anchor), .. } => {
saved = Some(anchor.author_path.clone());
break;
}
_ => break,
}
}
}
let path = if let Some(value) = p.get("author_path") {
value
.as_array()
.filter(|path| path.len() <= 64)
.ok_or_else(|| bad(cmd, "author_path must contain at most64 ids"))?
.iter()
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "invalid author_path id")))
.collect::<Result<Vec<_>>>()?
} else if let Some(path) = saved {
path
} else if feature.component == s.active_component && s.active_occurrence_path.is_some() {
s.active_occurrence_path.clone().unwrap_or_default()
} else {
let placements = super::component::properties::placements(&s.placed_document(), cmd)?;
let paths: Vec<_> = placements.into_iter().filter(|(component, _, _)| *component == feature.component).map(|(_, path, _)| path).collect();
if paths.len() != 1 {
return Err(bad(cmd, "sketch author placement is ambiguous; provide exact author_path or activate the intended occurrence"));
}
paths.into_iter().next().ok_or_else(|| bad(cmd, "sketch author placement is missing"))?
};
if feature.component != 0 && path.is_empty() {
return Err(bad(cmd, "a component sketch needs an explicit complete author_path"));
}
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path)?;
Ok(path)
}
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
let base = match p.get("plane") {
None => PlaneRef::Origin { name: "XY".into() },
// An origin plane, or a construction plane by name or id.
Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let placed = s.placed_document();
let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame());
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
let tol = (b.body.size() * 1e-3).max(1e-3);
for f in b.body.faces(tol).unwrap_or_default() {
if let Some(n) = f.plane_normal
&& (fp - f.centroid).dot(n).abs() < tol * 10.0
&& b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false)
{
// Sketch origin: the model origin projected onto the face plane.
let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n);
found = Plane::from_normal(n * d, n);
face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned();
}
}
}
let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?;
PlaneRef::Face { plane: pl, at: fp, name: face_name }
if o.contains_key("face") {
face::capture(s, v, cmd)?
} else {
let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?;
@@ -403,7 +426,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
/// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)?
fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
pub(super) fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| {
let Some([a, b, c]) = m.tri(t) else { return false };
let n = (b - a).cross(c - a);
@@ -423,13 +446,28 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.create";
let plane = plane_ref(s, p, cmd)?;
create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None)
}
/// Used only by face Extrude/Revolve/PressPull, preserving pre-certificate linked profiles.
pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: usize) -> Result<Value> {
let value = p.get("plane").ok_or_else(|| bad("face profile", "missing picked face plane"))?;
let plane = face::capture_internal(s, value, "face profile")?;
create_sketch_with_plane(s, p, plane, Some((body, index)))
}
fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<Value> {
if face::is_face(p) {
face::capture_positions(s, &plane)?;
}
// Validate the plane resolves.
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
let name = str_(p, "name");
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
super::component::to_active_frame(s, &mut kind);
if !face::is_face(p) {
super::component::to_active_frame(s, &mut kind);
}
let id = s.doc_mut().add_feature(kind, name)?;
let comp = s.active_component;
if let Some(f) = s.doc_mut().feature_mut(id) {
@@ -444,8 +482,23 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
&& bool_(p, "project_edges").unwrap_or(s.auto_project)
{
link = super::sketch_project::auto_project_face(s, id, at)?;
if link.is_none()
&& let Some((body, index)) = internal_body
{
link = Some(super::sketch_project::project_internal_face(s, id, body, index)?);
}
}
Ok(json!({"sketch": id, "name": name, "projected": link}))
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
"exact_face"
} else if internal_body.is_some() {
"internal_legacy_face"
} else {
"fixed"
}),
_ => None,
});
Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment}))
}
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
@@ -458,7 +511,17 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
}
let (id, name) = (f.id, f.name.clone());
let (id, name, component) = (f.id, f.name.clone(), f.component);
let authored_path = match &f.kind {
FeatureKind::Sketch { plane, .. } => face::anchor(plane).map(|anchor| anchor.author_path.clone()),
_ => None,
};
if let Some(path) = authored_path {
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), component, &path)
.map_err(|error| bad("sketch.edit", format!("{error}; explicitly rebind the author placement before editing")))?;
s.active_component = component;
s.active_occurrence_path = (!path.is_empty()).then_some(path);
}
s.active_sketch = Some(id);
s.revision += 1;
Ok(json!({"sketch": id, "name": name}))
@@ -1759,7 +1822,7 @@ fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
}
k => {
// Lines, splines and conics slide with the cursor.
for q in k.point_ids() {
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
if let Some(at) = sk.point(q) {
set(sk, q, at + d);
}