Merge remote-tracking branch 'origin/main' into feat/sketch-tools-return-ids

# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/cmd/sketch.rs
#	crates/engine/src/cmd/sketch_create.rs
#	crates/engine/src/cmd/sketch_modify.rs
#	crates/engine/src/lib.rs
#	docs/book/src/guide/sketching.md
This commit is contained in:
CypherNaugh_0x committed 2026-10-11 13:07:50 +02:00
commit b93d4905ac
181 files changed
+29350 -1572

No files matched your search

+133 -35
View File
@@ -9,6 +9,12 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
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`).
macro_rules! connect_doc {
() => {
@@ -20,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")
@@ -29,6 +35,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("L")
.enabled(in_sketch)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
.enabled(in_sketch)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
@@ -216,10 +225,16 @@ pub(super) fn edit<T>(
let out = f(&mut sk, &mut doc)?;
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, &mut sk)?;
let rep = solve(&mut sk);
sk.refresh_offsets()?;
let mut rep = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
sk.refresh_offsets()?;
rep = solve(&mut sk);
}
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})))
@@ -376,35 +391,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 st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
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"))?;
@@ -421,7 +457,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);
@@ -441,13 +477,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) {
@@ -462,8 +513,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> {
@@ -476,7 +542,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}))
@@ -823,6 +899,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
let mut support_circle = String::new();
let (out, info) = draw(s, p, cmd, |sk, _| {
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
let (l, pts) = polygon_lines(sk, &corners)?;
@@ -835,6 +912,7 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
// Construction circle tying the polygon to its centre.
let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() };
let circ = sk.add_circle(center, rc, None, None)?;
support_circle = sk.curves.get(circ).map(|c| c.id.clone()).unwrap_or_default();
if let Some(cu) = sk.curves.get_mut(circ) {
cu.construction = true;
}
@@ -849,13 +927,15 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
}
Ok((ids_of(sk, &l), cons))
})?;
Ok(result(out, info))
let mut value = result(out, info);
value["support_circle"] = json!(support_circle);
Ok(value)
}
fn sides(p: &Value, cmd: &str) -> Result<usize> {
let n = num(p, "sides").unwrap_or(6.0);
if !(3.0..=200.0).contains(&n) {
return Err(bad(cmd, "sides must be 3…200"));
if !(3.0..=200.0).contains(&n) || n.fract() != 0.0 {
return Err(bad(cmd, "sides must be an integer from 3 to 200"));
}
Ok(n as usize)
}
@@ -1364,9 +1444,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if ty == "sweep" => {
let angle = arc_len(sk, c).zip(sk.radius(c)).map(|(len, r)| len / r).ok_or_else(|| bad(cmd, "sweep needs an arc"))?;
let angle = if sk.curves.get(c).is_some_and(|c| c.reversed) { -angle } else { angle };
(ConstraintKind::ArcSweep { c, value: angle }, angle)
}
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
"angle" => {
let angle = (b - a).angle();
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
}
"horizontal" => {
(ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs())
}
@@ -1415,6 +1504,15 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
}
}
((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => {
let current = sk
.center(a)
.zip(sk.center(b))
.zip(sk.radius(a).zip(sk.radius(b)))
.map(|((ca, cb), (ra, rb))| ca.dist(cb) + ra + rb)
.ok_or_else(|| bad(cmd, "overall slot length needs two arc caps"))?;
(ConstraintKind::SlotOverallLength { a, b, value: current }, current)
}
((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => {
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
let (b0, b1) = line_pts(sk, b).unwrap_or_default();
@@ -1755,7 +1853,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);
}