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 -31
View File
@@ -9,9 +9,12 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse
use super::sketch::{add_c, edit, ids_of};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
#[path = "sketch_support.rs"]
mod support;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
.at("SKETCH", "MODIFY")
@@ -39,6 +42,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("O")
.enabled(in_sketch)
.params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
.enabled(in_sketch)
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
CommandSpec::new("sketch.trim", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
@@ -54,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]", created_doc!())),
.params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
@@ -89,6 +95,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
if sk.is_linked_curve(c) {
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first")));
}
if sk.freeform_offsets.iter().any(|offset| offset.output == id) {
return Err(bad(cmd, "break the offset association before editing its output"));
}
Ok(c)
}
@@ -135,6 +144,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
}
}
}
for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) {
if let Some(t) = on_bounded(&sh, q)
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
{
out.push((t, q, oi));
}
}
out
}
@@ -314,17 +330,24 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
let at = req_vec2(cmd, p, "at")?;
let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?;
let r = if let Some(points) = &points {
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
} else {
let at = req_vec2(cmd, p, "at")?;
super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
};
doc.prune_model_params();
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
}
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = p.get("points").and_then(Value::as_array) {
let mut v: Vec<f64> = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect();
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = &points {
let mut v: Vec<f64> = list.iter().map(|q| sh.param(sh.project(*q))).collect();
v.sort_by(|a, b| a.total_cmp(b));
v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
let n = v.len();
(v, vec![None; n])
} else {
@@ -351,7 +374,12 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
}
let ci = curve_arg(sk, p, cmd)?;
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) {
@@ -372,41 +400,62 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
};
let (_, t1) = sh.range();
let mut best: Option<(f64, Vec2, usize)> = None;
let mut ambiguous = false;
let mut budget = 1_000_000usize;
let mut segment_count = 0usize;
for (oi, _) in sk.curves.iter().enumerate() {
if oi == ci {
continue;
}
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
// pieces of their polyline.
let pieces: Vec<Shape> = match sk.shape(oi) {
Some(other) => vec![other],
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
};
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
let l = a.dist(b);
if at_b { (t - 1.0) * l } else { -t * l }
}
Shape::Round { r, .. } => {
if at_b {
(t - t1) * r
} else {
(TAU - t) * r
let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
segment_count += pieces.len();
if segment_count > 16_384 {
return Err(bad(cmd, "too many exact curve pieces to extend safely"));
}
for piece in &pieces {
for q in support::hits(&unbounded, piece, &mut budget)? {
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
let l = a.dist(b);
if at_b { (t - 1.0) * l } else { -t * l }
}
Shape::Round { r, .. } => {
// A hit already inside the arc cannot lengthen either end. In
// particular, extending the start must not turn a quarter into an eighth.
if t <= t1 + TOL / r.max(1e-12) || t >= TAU - TOL / r.max(1e-12) {
continue;
}
if at_b { (t - t1) * r } else { (TAU - t) * r }
}
};
if gap > TOL * 10.0 {
if best.is_none_or(|b| gap < b.0 - TOL) {
best = Some((gap, q, oi));
ambiguous = false;
} else if best.is_some_and(|b| (gap - b.0).abs() <= TOL && (b.2 != oi || b.1.dist(q) > TOL)) {
ambiguous = true;
}
}
};
if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) {
best = Some((gap, q, oi));
}
}
}
if ambiguous {
return Err(bad(cmd, "multiple curves stop the extension at the same distance"));
}
let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?;
if let Some(pt) = sk.points.get_mut(end) {
pt.pos = q;
}
pin(sk, end, oi)?;
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
{
return Err(bad(cmd, "the extension conflicts with constraints or cannot preserve its original support"));
}
Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default())
})?;
Ok(json!({"moved": out, "sketch": info}))
@@ -795,7 +844,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?;
let side = p.get("side").and_then(vec2);
let use_chain = bool_(p, "chain").unwrap_or(true);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve existing constraints before offsetting"));
}
sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
let mut made = Vec::new();
@@ -841,6 +897,17 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
None => d0,
};
if order.iter().any(|(i, _)| sk.shape(*i).is_none()) {
// A smooth free-form entity is offset from exact pieces. Junctions that
// would require trimming/intersecting independent normal offsets are
// refused rather than joined by a guessed average point.
if order.len() != 1 {
return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous"));
}
let (source, forward) = order[0];
let output = sk.add_freeform_offset(source, forward, d)?;
return Ok((ids_of(sk, &[output]), cons));
}
let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
let n = pieces.len();
@@ -903,7 +970,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
idx.push(pb);
idx.dedup();
if idx.len() >= 2 {
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?);
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?);
}
}
Piece::Line(..) => {
@@ -933,6 +1000,41 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset.break";
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
let record = sk
.freeform_offsets
.iter()
.position(|record| record.output == output)
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
// A parameter edit may have changed the evaluated source without
// rewriting the stored fit points. Break must freeze the displayed,
// current dimensioned geometry, rather than its stale stored predecessor.
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
}
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
}
sk.validate_periodic_splines()?;
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
let indices = sk.curves[ci].kind.point_ids();
sk.freeform_offsets.remove(record);
for i in indices {
if let Some(point) = sk.points.get_mut(i) {
point.fixed = false;
}
}
Ok(output.to_string())
})?;
Ok(json!({"curve":out,"sketch":info}))
}
// ---------------------------------------------------------------------------------------------
// Copies: mirror, patterns, move, scale