Sketch Mirror, rectangular and circular Pattern, and Offset now show a green live preview that follows the selection and settings; OK commits, Cancel leaves the sketch unchanged. The shared dialogs show the current selection step and source counts, sources can be toggled with Ctrl/Cmd-click, cleared and reselected. Offsets keep editable driving distance dimensions (One Side, Two Sides, Symmetric) that update the derived curves without moving the source, with undo/redo and save/reopen. Format 19: older files keep their fixed offsets. New dialog labels in en, zh, ja, de and ko. Fixes #696. Fixes #517.
1731 lines
78 KiB
Rust
1731 lines
78 KiB
Rust
//! Sketch modify tools: trim, extend, break, fillet, chamfer, offset, mirror, circular and
|
||
//! rectangular patterns, move/copy and scale.
|
||
|
||
use std::f64::consts::TAU;
|
||
|
||
use serde_json::{Value, json};
|
||
use solidcraft_doc::ProfileSel;
|
||
use solidcraft_geom::Vec2;
|
||
use solidcraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
|
||
|
||
use super::sketch::{add_c, edit, ids_of, solve_current};
|
||
use super::{CommandSpec, in_sketch};
|
||
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")
|
||
.icon("sketch_fillet")
|
||
.enabled(in_sketch)
|
||
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())),
|
||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("sketch_chamfer")
|
||
.enabled(in_sketch)
|
||
.params(concat!("point | a, b: a corner of two lines; distance", created_doc!())),
|
||
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("sketch_chamfer")
|
||
.enabled(in_sketch)
|
||
.params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())),
|
||
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("sketch_chamfer")
|
||
.enabled(in_sketch)
|
||
.params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())),
|
||
CommandSpec::new("sketch.offset", "Offset", offset)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("offset")
|
||
.key("O")
|
||
.enabled(in_sketch)
|
||
.params(concat!(
|
||
"curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ",
|
||
"direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way, as for `solid.extrude`; no `side`, whose job it takes over), ",
|
||
"distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions",
|
||
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")
|
||
.key("T")
|
||
.enabled(in_sketch)
|
||
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())),
|
||
CommandSpec::new("sketch.extend", "Extend", extend)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("extend")
|
||
.enabled(in_sketch)
|
||
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
|
||
CommandSpec::new("sketch.break", "Break", break_cmd)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("break")
|
||
.enabled(in_sketch)
|
||
.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")
|
||
.enabled(in_sketch)
|
||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
|
||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
|
||
.at("SKETCH", "MODIFY")
|
||
.icon("move")
|
||
.enabled(in_sketch)
|
||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
|
||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||
.at("SKETCH", "CREATE")
|
||
.icon("sketch_mirror")
|
||
.enabled(in_sketch)
|
||
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
|
||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||
.at("SKETCH", "CREATE")
|
||
.icon("sketch_circ_pattern")
|
||
.enabled(in_sketch)
|
||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)", created_doc!())),
|
||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||
.at("SKETCH", "CREATE")
|
||
.icon("sketch_rect_pattern")
|
||
.enabled(in_sketch)
|
||
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())),
|
||
];
|
||
|
||
const TOL: f64 = 1e-7;
|
||
const MAX_COPIES: usize = 500;
|
||
|
||
fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
|
||
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a curve id"))?;
|
||
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||
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)
|
||
|| sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.roles.iter().any(|role| role.output == id))
|
||
{
|
||
return Err(bad(cmd, "break the offset association before editing its output"));
|
||
}
|
||
Ok(c)
|
||
}
|
||
|
||
/// Parameter of `p` on the bounded shape, ends included.
|
||
fn on_bounded(sh: &Shape, p: Vec2) -> Option<f64> {
|
||
if sh.dist(p) > TOL * 10.0 {
|
||
return None;
|
||
}
|
||
let t = sh.param(p);
|
||
let (t0, t1) = sh.range();
|
||
let slack = match *sh {
|
||
Shape::Line { a, b } => TOL * 10.0 / a.dist(b).max(1e-12),
|
||
Shape::Round { r, .. } => TOL * 10.0 / r.max(1e-12),
|
||
};
|
||
if sh.is_full() || (t >= t0 - slack && t <= t1 + slack) { Some(t) } else { None }
|
||
}
|
||
|
||
/// Points where other curves meet curve `ci` (crossings and end points touching it), with the
|
||
/// curve that makes each one and the parameter on `ci`.
|
||
fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
|
||
let Some(sh) = sk.shape(ci) else { return Vec::new() };
|
||
let mut out: Vec<(f64, Vec2, usize)> = Vec::new();
|
||
for (oi, _) in sk.curves.iter().enumerate() {
|
||
if oi == ci {
|
||
continue;
|
||
}
|
||
let Some(other) = sk.shape(oi) else { continue };
|
||
let mut pts = intersections(&sh, &other);
|
||
// Overlapping or touching ends that the analytic intersection misses (parallel lines).
|
||
if let Shape::Line { a, b } = other {
|
||
pts.extend([a, b]);
|
||
}
|
||
if let Shape::Round { sweep, .. } = other
|
||
&& sweep < TAU - 1e-12
|
||
{
|
||
pts.extend([other.point(0.0), other.point(sweep)]);
|
||
}
|
||
for q in pts {
|
||
if on_bounded(&other, q).is_some()
|
||
&& let Some(t) = on_bounded(&sh, q)
|
||
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
|
||
{
|
||
out.push((t, q, oi));
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Constrain a new end point onto the curve that cut there.
|
||
fn pin(sk: &mut Sketch, p: usize, cutter: usize) -> Result<()> {
|
||
let at = sk.point(p).unwrap_or_default();
|
||
// An end point of the cutter there: coincident with it; otherwise on the curve.
|
||
let ends: Vec<usize> = match sk.curves.get(cutter).map(|c| c.kind.clone()) {
|
||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => vec![a, b],
|
||
_ => Vec::new(),
|
||
};
|
||
if let Some(e) = ends.into_iter().find(|e| sk.point(*e).is_some_and(|q| q.dist(at) < TOL * 10.0)) {
|
||
if e != p {
|
||
add_c(sk, ConstraintKind::Coincident { p, q: e })?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
add_c(sk, ConstraintKind::PointOnCurve { p, c: cutter })?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Split curve `ci` at interior parameters `ts` (sorted). Returns the pieces in parameter
|
||
/// order (the first keeps the original index) and the new points.
|
||
fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<usize>)> {
|
||
let sh = sk.shape(ci).ok_or_else(|| bad("split", "curve"))?;
|
||
let kind = sk.curves.get(ci).map(|c| c.kind.clone()).ok_or_else(|| bad("split", "curve"))?;
|
||
let construction = sk.curves.get(ci).is_some_and(|c| c.construction);
|
||
let mut pts = Vec::new();
|
||
for t in ts {
|
||
pts.push(sk.add_point(sh.point(*t), None)?);
|
||
}
|
||
let mut pieces = vec![ci];
|
||
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
|
||
let id = sk.fresh(prefix)?;
|
||
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false });
|
||
pieces.push(sk.curves.len() - 1);
|
||
Ok(())
|
||
};
|
||
match kind {
|
||
CurveKind::Line { a, b } => {
|
||
let mut chain = vec![a];
|
||
chain.extend(&pts);
|
||
chain.push(b);
|
||
if let Some(c) = sk.curves.get_mut(ci) {
|
||
c.kind = CurveKind::Line { a, b: chain.get(1).copied().unwrap_or(b) };
|
||
}
|
||
for w in chain.windows(2).skip(1) {
|
||
push(sk, CurveKind::Line { a: w[0], b: w[1] }, "l")?;
|
||
}
|
||
}
|
||
CurveKind::Arc { c, a, b } => {
|
||
let mut chain = vec![a];
|
||
chain.extend(&pts);
|
||
chain.push(b);
|
||
if let Some(cu) = sk.curves.get_mut(ci) {
|
||
cu.kind = CurveKind::Arc { c, a, b: chain.get(1).copied().unwrap_or(b) };
|
||
}
|
||
for w in chain.windows(2).skip(1) {
|
||
push(sk, CurveKind::Arc { c, a: w[0], b: w[1] }, "a")?;
|
||
}
|
||
}
|
||
CurveKind::Circle { c, .. } => {
|
||
if pts.len() < 2 {
|
||
return Err(bad("split", "a circle needs two points to split at"));
|
||
}
|
||
let mut chain = pts.clone();
|
||
if let Some(f) = pts.first() {
|
||
chain.push(*f);
|
||
}
|
||
if let Some(cu) = sk.curves.get_mut(ci) {
|
||
cu.kind = CurveKind::Arc { c, a: chain[0], b: chain[1] };
|
||
cu.reversed = false;
|
||
}
|
||
for w in chain.windows(2).skip(1) {
|
||
push(sk, CurveKind::Arc { c, a: w[0], b: w[1] }, "a")?;
|
||
}
|
||
}
|
||
_ => return Err(bad("split", "not supported yet for ellipses, splines and conics")),
|
||
}
|
||
Ok((pieces, pts))
|
||
}
|
||
|
||
/// Keep the pieces of a split tied together: lines collinear, arcs on one circle.
|
||
fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> {
|
||
for w in pieces.windows(2) {
|
||
let (a, b) = (w[0], w[1]);
|
||
let line = matches!(sk.curves.get(a).map(|c| &c.kind), Some(CurveKind::Line { .. }));
|
||
add_c(sk, if line { ConstraintKind::Collinear { a, b } } else { ConstraintKind::Equal { a, b } })?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves.
|
||
fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||
let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) };
|
||
let (t0, t1) = sh.range();
|
||
let full = sh.is_full();
|
||
let span = t1 - t0;
|
||
let mut cuts: Vec<(f64, Vec2, usize)> =
|
||
cut_points(sk, ci).into_iter().filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span)).collect();
|
||
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
if full {
|
||
if cuts.len() < 2 {
|
||
return (Vec::new(), true);
|
||
}
|
||
// Cuts around tq on the circle (wrapping).
|
||
let after = cuts.iter().position(|c| c.0 > tq).unwrap_or(0);
|
||
let before = (after + cuts.len() - 1) % cuts.len();
|
||
let mut two = vec![cuts[before], cuts[after]];
|
||
two.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
return (two, true);
|
||
}
|
||
let before = cuts.iter().rev().find(|c| c.0 < tq).copied();
|
||
let after = cuts.iter().find(|c| c.0 > tq).copied();
|
||
(before.into_iter().chain(after).collect(), false)
|
||
}
|
||
|
||
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.trim";
|
||
let at = req_vec2(cmd, p, "at")?;
|
||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||
let ci = curve_arg(sk, p, cmd)?;
|
||
let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default();
|
||
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
|
||
let r = super::sketch_freeform::trim_or_break(sk, ci, at, true, cmd)?;
|
||
doc.prune_model_params();
|
||
return Ok(json!({"trimmed": id, "pieces": r["curves"]}));
|
||
}
|
||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||
let tq = sh.param(sh.project(at));
|
||
let (cuts, full) = bracket(sk, ci, tq);
|
||
if cuts.is_empty() {
|
||
// Nothing crosses it: the whole curve goes.
|
||
sk.remove_curves(&[ci]);
|
||
doc.prune_model_params();
|
||
return Ok(json!({"deleted": id}));
|
||
}
|
||
let ts: Vec<f64> = cuts.iter().map(|c| c.0).collect();
|
||
let (pieces, pts) = split_at(sk, ci, &ts)?;
|
||
// The piece containing tq.
|
||
let k = if full {
|
||
let (a, b) = (ts[0], ts[1]);
|
||
if tq > a && tq < b { 0 } else { 1 }
|
||
} else {
|
||
ts.iter().filter(|t| **t < tq).count()
|
||
};
|
||
for (pi, c) in pts.iter().zip(&cuts) {
|
||
pin(sk, *pi, c.2)?;
|
||
}
|
||
let gone = pieces.get(k).copied().ok_or_else(|| bad(cmd, "piece"))?;
|
||
let keep: Vec<usize> = pieces.iter().copied().filter(|x| *x != gone).collect();
|
||
// The original curve keeps its id (and constraints): swap geometry into it if needed.
|
||
let gone = match keep.first() {
|
||
Some(&other) if gone == ci => {
|
||
let ka = sk.curves.get(ci).map(|c| c.kind.clone());
|
||
let kb = sk.curves.get(other).map(|c| c.kind.clone());
|
||
if let (Some(ka), Some(kb)) = (ka, kb) {
|
||
if let Some(c) = sk.curves.get_mut(ci) {
|
||
c.kind = kb;
|
||
}
|
||
if let Some(c) = sk.curves.get_mut(other) {
|
||
c.kind = ka;
|
||
}
|
||
}
|
||
other
|
||
}
|
||
_ => gone,
|
||
};
|
||
let keep: Vec<usize> = pieces.iter().copied().filter(|x| *x != gone).collect();
|
||
tie(sk, &keep)?;
|
||
sk.remove_curves(&[gone]);
|
||
doc.prune_model_params();
|
||
Ok(json!({"trimmed": id}))
|
||
})?;
|
||
Ok(json!({"result": out, "sketch": info}))
|
||
}
|
||
|
||
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.break";
|
||
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 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) = &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 {
|
||
let at = req_vec2(cmd, p, "at")?;
|
||
let (cuts, _) = bracket(sk, ci, sh.param(sh.project(at)));
|
||
(cuts.iter().map(|c| c.0).collect(), cuts.iter().map(|c| Some(c.2)).collect())
|
||
};
|
||
let (t0, t1) = sh.range();
|
||
if ts.is_empty() || (!sh.is_full() && ts.iter().any(|t| *t <= t0 || *t >= t1)) {
|
||
return Err(bad(cmd, "nothing to break at there"));
|
||
}
|
||
let (pieces, pts) = split_at(sk, ci, &ts)?;
|
||
for (pi, c) in pts.iter().zip(&cutters) {
|
||
if let Some(c) = c {
|
||
pin(sk, *pi, *c)?;
|
||
}
|
||
}
|
||
tie(sk, &pieces)?;
|
||
Ok(ids_of(sk, &pieces))
|
||
})?;
|
||
Ok(json!({"curves": out, "sketch": info}))
|
||
}
|
||
|
||
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, true, |sk, doc| {
|
||
let rep = solve_current(sk, doc)?;
|
||
if !rep.keeps_orientation() {
|
||
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
|
||
}
|
||
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()) {
|
||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => (a, b),
|
||
_ => return Err(bad(cmd, "only lines and arcs can be extended")),
|
||
};
|
||
let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default());
|
||
let at_b = at.dist(qb) <= at.dist(qa);
|
||
let end = if at_b { pb } else { pa };
|
||
let shared = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && uses(c, end)).count();
|
||
if shared > 0 || sk.points.get(end).is_some_and(|q| q.fixed) {
|
||
return Err(bad(cmd, "that end is connected to other geometry"));
|
||
}
|
||
// Candidate hits on the unbounded curve beyond the chosen end.
|
||
let unbounded = match sh {
|
||
Shape::Line { .. } => sh,
|
||
Shape::Round { c, r, start, .. } => Shape::Round { c, r, start, sweep: TAU },
|
||
};
|
||
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;
|
||
}
|
||
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 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 = solidcraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
|
||
if !solidcraft_sketch::solve_with(sk, &hold, None, solidcraft_sketch::Analysis::Full, None).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}))
|
||
}
|
||
|
||
fn uses(c: &Curve, p: usize) -> bool {
|
||
c.kind.uses(p)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Corners: fillet and chamfer
|
||
|
||
/// A corner of two lines: (line a, line b, corner point, far end of a, far end of b).
|
||
fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> {
|
||
let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) {
|
||
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
|
||
_ => None,
|
||
};
|
||
let (la, lb) = if let Some(r) = str_(p, "point") {
|
||
let q = sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?;
|
||
let lines: Vec<usize> = (0..sk.curves.len()).filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i)).collect();
|
||
match lines[..] {
|
||
[a, b] => (a, b),
|
||
_ => return Err(bad(cmd, "the corner must join exactly two lines")),
|
||
}
|
||
} else {
|
||
let a = str_(p, "a").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`a` must be a line"))?;
|
||
let b = str_(p, "b").and_then(|i| sk.curve_index(i)).ok_or_else(|| bad(cmd, "`b` must be a line"))?;
|
||
(a, b)
|
||
};
|
||
let (a0, a1) = ends(la).ok_or_else(|| bad(cmd, "fillets and chamfers work on two lines"))?;
|
||
let (b0, b1) = ends(lb).ok_or_else(|| bad(cmd, "fillets and chamfers work on two lines"))?;
|
||
let shared = [a0, a1].into_iter().find(|x| *x == b0 || *x == b1).ok_or_else(|| bad(cmd, "the lines must share a corner point"))?;
|
||
let fa = if a0 == shared { a1 } else { a0 };
|
||
let fb = if b0 == shared { b1 } else { b0 };
|
||
Ok((la, lb, shared, fa, fb))
|
||
}
|
||
|
||
/// Replace the corner end of line `l` with point `np`.
|
||
fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) {
|
||
if let Some(Curve { kind: CurveKind::Line { a, b }, .. }) = sk.curves.get_mut(l) {
|
||
if *a == corner {
|
||
*a = np;
|
||
} else if *b == corner {
|
||
*b = np;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id),
|
||
/// and the constraints and dimensions that now measure to it.
|
||
#[derive(Default)]
|
||
struct CornerKept {
|
||
virtual_corner: Option<String>,
|
||
moved: Vec<String>,
|
||
}
|
||
|
||
/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`)
|
||
/// when anything measures to it: a constraint or dimension on the point, the length of either
|
||
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
|
||
/// held on both curves' extensions (their intersection), and the line lengths become distances
|
||
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
|
||
/// caller removes the point.
|
||
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
|
||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||
let mut moved: Vec<String> = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect();
|
||
for k in sk.constraints.iter_mut() {
|
||
let ConstraintKind::Length { l, value } = k.kind else { continue };
|
||
let new_end = if l == ca {
|
||
na
|
||
} else if l == cb {
|
||
nb
|
||
} else {
|
||
continue;
|
||
};
|
||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue };
|
||
// Same direction as the line, so the dimension keeps its side and text place.
|
||
k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } };
|
||
moved.push(k.id.clone());
|
||
}
|
||
if moved.is_empty() && !used && c != 0 {
|
||
return Ok(None);
|
||
}
|
||
for cu in [ca, cb] {
|
||
let k = ConstraintKind::PointOnCurve { p: c, c: cu };
|
||
if !sk.constraints.iter().any(|x| x.kind == k) {
|
||
add_c(sk, k)?;
|
||
}
|
||
}
|
||
// A point of its own now: `l1.end` names the line's new end, not the virtual corner.
|
||
let virtual_corner = if used || c == 0 {
|
||
None
|
||
} else {
|
||
let id = sk.fresh("corner")?;
|
||
if let Some(pt) = sk.points.get_mut(c) {
|
||
pt.id = id.clone();
|
||
}
|
||
Some(id)
|
||
};
|
||
Ok(Some(CornerKept { virtual_corner, moved }))
|
||
}
|
||
|
||
/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them.
|
||
fn cut_corner(
|
||
sk: &mut Sketch,
|
||
la: usize,
|
||
lb: usize,
|
||
c: usize,
|
||
fa: usize,
|
||
fb: usize,
|
||
da: f64,
|
||
db: f64,
|
||
cmd: &str,
|
||
) -> Result<(usize, usize, Vec2, CornerKept)> {
|
||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||
if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) {
|
||
return Err(bad(cmd, "too large for these lines"));
|
||
}
|
||
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||
let ta = sk.add_point(pc + u * da, None)?;
|
||
let tb = sk.add_point(pc + v * db, None)?;
|
||
reattach(sk, la, c, ta);
|
||
reattach(sk, lb, c, tb);
|
||
let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?;
|
||
let removed = kept.is_none();
|
||
if removed {
|
||
sk.remove_points(&[c]);
|
||
}
|
||
// Indices shift when the corner point was removed.
|
||
let fix = |i: usize| if removed && i > c { i - 1 } else { i };
|
||
Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default()))
|
||
}
|
||
|
||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.fillet";
|
||
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
|
||
// Several corners at once (`points`), each like a single `point`.
|
||
let corners: Vec<Value> = match p.get("points").and_then(Value::as_array) {
|
||
Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(|q| json!({ "point": q })).collect(),
|
||
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
|
||
None => vec![p.clone()],
|
||
};
|
||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||
let r = doc.eval(&r_expr, solidcraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
|
||
if !(r > 1e-9 && r < 1e8) {
|
||
return Err(bad(cmd, "radius must be positive"));
|
||
}
|
||
let pname = doc.new_model_param(&r_expr, "mm");
|
||
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
|
||
for q in &corners {
|
||
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
|
||
kept.push(k);
|
||
// One radius for them all: the first carries the dimension, the rest are equal to it.
|
||
match arcs.first() {
|
||
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
|
||
Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?),
|
||
}
|
||
cons.extend(c);
|
||
arcs.push(arc);
|
||
}
|
||
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
|
||
})?;
|
||
let (vc, moved) = out.3;
|
||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||
}
|
||
|
||
/// The virtual corners kept and the constraints moved to them, for a response.
|
||
fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
|
||
let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect();
|
||
let mut moved: Vec<String> = Vec::new();
|
||
for id in kept.into_iter().flat_map(|k| k.moved) {
|
||
if !moved.contains(&id) {
|
||
moved.push(id);
|
||
}
|
||
}
|
||
(vcs, moved)
|
||
}
|
||
|
||
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
|
||
/// the arc, the tangent constraints and what became of the corner point.
|
||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
|
||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||
Ok(x) => x,
|
||
// Not two lines: a corner with an arc.
|
||
Err(e) => return round_corner(sk, p, r, cmd)?.ok_or(e),
|
||
};
|
||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
|
||
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
|
||
return Err(bad(cmd, "the lines are parallel"));
|
||
}
|
||
let d = r / half.tan();
|
||
if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 {
|
||
return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)")));
|
||
}
|
||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||
let center = pc + bis * (r / half.sin());
|
||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||
let arc = if ccw {
|
||
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
|
||
} else {
|
||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||
};
|
||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||
Ok((arc, cons, kept))
|
||
}
|
||
|
||
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
|
||
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
|
||
/// that kind.
|
||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>, CornerKept)>> {
|
||
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
|
||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
|
||
_ => None,
|
||
};
|
||
let (ca, cb, q) = if let Some(rf) = str_(p, "point") {
|
||
let Some(q) = sk.resolve_point(rf) else { return Ok(None) };
|
||
let cs: Vec<usize> = (0..sk.curves.len()).filter(|i| ends(*i).is_some_and(|(a, b)| a == q || b == q) && !sk.is_linked_curve(*i)).collect();
|
||
match cs[..] {
|
||
[a, b] => (a, b, q),
|
||
_ => return Ok(None),
|
||
}
|
||
} else {
|
||
let (Some(a), Some(b)) = (str_(p, "a").and_then(|i| sk.curve_index(i)), str_(p, "b").and_then(|i| sk.curve_index(i))) else {
|
||
return Ok(None);
|
||
};
|
||
let (Some((a0, a1)), Some((b0, b1))) = (ends(a), ends(b)) else { return Ok(None) };
|
||
let Some(q) = [a0, a1].into_iter().find(|x| *x == b0 || *x == b1) else { return Ok(None) };
|
||
(a, b, q)
|
||
};
|
||
let (Some(sa), Some(sb), Some(pc)) = (sk.shape(ca), sk.shape(cb), sk.point(q)) else { return Ok(None) };
|
||
let offsets = |sh: &Shape| -> Vec<Shape> {
|
||
match *sh {
|
||
Shape::Line { a, b } => match (b - a).normalized() {
|
||
Some(d) => [1.0, -1.0].iter().map(|s| Shape::Line { a: a + d.perp() * (r * s), b: b + d.perp() * (r * s) }).collect(),
|
||
None => Vec::new(),
|
||
},
|
||
Shape::Round { c, r: rr, .. } => {
|
||
let mut v = vec![Shape::Round { c, r: rr + r, start: 0.0, sweep: TAU }];
|
||
if rr - r > 1e-9 {
|
||
v.push(Shape::Round { c, r: rr - r, start: 0.0, sweep: TAU });
|
||
}
|
||
v
|
||
}
|
||
}
|
||
};
|
||
let mut best: Option<(f64, Vec2, Vec2, Vec2)> = None;
|
||
for oa in offsets(&sa) {
|
||
for ob in offsets(&sb) {
|
||
for cen in intersections(&oa, &ob) {
|
||
let (ta, tb) = (sa.project(cen), sb.project(cen));
|
||
if on_bounded(&sa, ta).is_none() || on_bounded(&sb, tb).is_none() || ta.dist(pc) < 1e-9 || tb.dist(pc) < 1e-9 {
|
||
continue;
|
||
}
|
||
let score = ta.dist(pc) + tb.dist(pc);
|
||
if best.is_none_or(|b| score < b.0) {
|
||
best = Some((score, cen, ta, tb));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let (_, cen, ta, tb) = best.ok_or_else(|| bad(cmd, "that radius does not fit the corner"))?;
|
||
let (na, nb) = (sk.add_point(ta, None)?, sk.add_point(tb, None)?);
|
||
for (c, n) in [(ca, na), (cb, nb)] {
|
||
if let Some(cu) = sk.curves.get_mut(c) {
|
||
cu.kind.map_points(&|i| if i == q { n } else { i });
|
||
}
|
||
}
|
||
let ccw = (ta - cen).cross(tb - cen) > 0.0;
|
||
let arc =
|
||
if ccw { sk.add_arc(cen, ta, tb, [None, Some(na), Some(nb)], None)? } else { sk.add_arc(cen, tb, ta, [None, Some(nb), Some(na)], None)? };
|
||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: arc })?];
|
||
match keep_corner(sk, q, [(ca, na), (cb, nb)])? {
|
||
Some(kept) => Ok(Some((arc, cons, kept))),
|
||
None => {
|
||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||
sk.remove_points(&[q]);
|
||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||
Ok(Some((arc, cons, CornerKept::default())))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (la, lb, c, fa, fb) = corner(sk, p, cmd)?;
|
||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
|
||
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
|
||
let (da, db) = dists(corner_angle)?;
|
||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||
let l = sk.add_line_pts(ta, tb, None)?;
|
||
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
|
||
})?;
|
||
let (curves, (vc, moved)) = out;
|
||
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||
}
|
||
|
||
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.chamfer.equal_distance";
|
||
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
|
||
chamfer(s, p, cmd, |_| Ok((d, d)))
|
||
}
|
||
|
||
fn chamfer_two(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.chamfer.two_distance";
|
||
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
|
||
let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?;
|
||
chamfer(s, p, cmd, |_| Ok((d, d2)))
|
||
}
|
||
|
||
fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.chamfer.distance_angle";
|
||
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
|
||
let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians();
|
||
chamfer(s, p, cmd, |phi| {
|
||
// Triangle corner–Ta–Tb: angle phi at the corner, `a` at Ta.
|
||
let opp = std::f64::consts::PI - a - phi;
|
||
if !(a > 1e-6 && opp > 1e-6) {
|
||
return Err(bad(cmd, "that angle does not meet the other line"));
|
||
}
|
||
Ok((d, d * a.sin() / opp.sin()))
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Offset
|
||
|
||
/// Curves of the connected chain through `start` (joined where exactly two curves meet), in
|
||
/// order, with whether each is walked from its stored start (`a`) to its end (`b`).
|
||
fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool) {
|
||
let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends());
|
||
let Some((s0, s1)) = ends(start) else { return (vec![(start, true)], true) };
|
||
let next = |from: usize, at: usize, used: &[usize]| -> Option<usize> {
|
||
let users: Vec<usize> = pool.iter().copied().filter(|c| ends(*c).is_some_and(|(a, b)| a == at || b == at)).collect();
|
||
if users.len() != 2 {
|
||
return None;
|
||
}
|
||
users.into_iter().find(|c| *c != from && !used.contains(c))
|
||
};
|
||
let mut fwd = vec![(start, true)];
|
||
let mut used = vec![start];
|
||
let (mut cur, mut at) = (start, s1);
|
||
let mut closed = false;
|
||
while let Some(n) = next(cur, at, &used) {
|
||
let Some((a, b)) = ends(n) else { break };
|
||
let forward = a == at;
|
||
fwd.push((n, forward));
|
||
used.push(n);
|
||
cur = n;
|
||
at = if forward { b } else { a };
|
||
if used.len() > 10_000 {
|
||
break;
|
||
}
|
||
}
|
||
if at == s0 && fwd.len() > 1 {
|
||
closed = true;
|
||
} else {
|
||
// Walk backwards from the start.
|
||
let (mut cur, mut at) = (start, s0);
|
||
let mut back = Vec::new();
|
||
while let Some(n) = next(cur, at, &used) {
|
||
let Some((a, b)) = ends(n) else { break };
|
||
let forward = b == at;
|
||
back.push((n, forward));
|
||
used.push(n);
|
||
cur = n;
|
||
at = if forward { a } else { b };
|
||
if used.len() > 10_000 {
|
||
break;
|
||
}
|
||
}
|
||
back.reverse();
|
||
back.extend(fwd);
|
||
fwd = back;
|
||
}
|
||
(fwd, closed)
|
||
}
|
||
|
||
/// An offset piece: shape walked in chain direction, offset by `d` to the left.
|
||
#[derive(Clone)]
|
||
enum Piece {
|
||
Line(Vec2, Vec2),
|
||
/// Centre point index, centre, radius, start point, end point (walk direction), ccw walk.
|
||
Arc(usize, Vec2, f64, Vec2, Vec2, bool),
|
||
/// A free-form curve's offset, sampled in walk direction (made into a fit spline).
|
||
Free(Vec<Vec2>),
|
||
}
|
||
|
||
fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option<Piece> {
|
||
match sk.curves.get(c)?.kind {
|
||
CurveKind::Line { a, b } => {
|
||
let (mut p, mut q) = (sk.point(a)?, sk.point(b)?);
|
||
if !forward {
|
||
std::mem::swap(&mut p, &mut q);
|
||
}
|
||
let n = (q - p).normalized()?.perp();
|
||
Some(Piece::Line(p + n * d, q + n * d))
|
||
}
|
||
CurveKind::Arc { c: ci, a, b } => {
|
||
let (cc, pa, pb) = (sk.point(ci)?, sk.point(a)?, sk.point(b)?);
|
||
let r = cc.dist(pa);
|
||
// Walking counter-clockwise, left is toward the centre.
|
||
let nr = if forward { r - d } else { r + d };
|
||
if nr <= 1e-9 {
|
||
return None;
|
||
}
|
||
let at = |q: Vec2| cc + (q - cc).normalized().unwrap_or(Vec2::X) * nr;
|
||
let (s, e) = if forward { (at(pa), at(pb)) } else { (at(pb), at(pa)) };
|
||
Some(Piece::Arc(ci, cc, nr, s, e, forward))
|
||
}
|
||
CurveKind::Circle { .. } => None,
|
||
// Free-form: offset the curve's samples along their normals.
|
||
_ => {
|
||
let mut poly = sk.polyline(c);
|
||
if !forward {
|
||
poly.reverse();
|
||
}
|
||
let n = poly.len();
|
||
let pts: Vec<Vec2> = (0..n)
|
||
.filter_map(|i| {
|
||
let (a, b) = (poly.get(i.saturating_sub(1))?, poly.get((i + 1).min(n - 1))?);
|
||
let t = (*b - *a).normalized()?;
|
||
Some(*poly.get(i)? + t.perp() * d)
|
||
})
|
||
.collect();
|
||
(pts.len() >= 2).then_some(Piece::Free(pts))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn piece_shape(p: &Piece) -> Shape {
|
||
match p {
|
||
Piece::Line(a, b) => Shape::Line { a: *a, b: *b },
|
||
Piece::Arc(_, c, r, ..) => Shape::Round { c: *c, r: *r, start: 0.0, sweep: TAU },
|
||
Piece::Free(v) => Shape::Line { a: v.first().copied().unwrap_or_default(), b: v.last().copied().unwrap_or_default() },
|
||
}
|
||
}
|
||
|
||
/// Distance from `q` to an offset piece.
|
||
fn piece_dist(p: &Piece, q: Vec2) -> f64 {
|
||
match p {
|
||
Piece::Free(v) => v
|
||
.windows(2)
|
||
.map(|w| {
|
||
let (a, b) = (w[0], w[1]);
|
||
let t = if (b - a).len2() > 0.0 { ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||
q.dist(a + (b - a) * t)
|
||
})
|
||
.fold(f64::INFINITY, f64::min),
|
||
_ => piece_shape(p).dist(q),
|
||
}
|
||
}
|
||
|
||
/// One side of an offset. The curves created at the signed distance, the parallel constraints
|
||
/// added with them, and the association when the boundary carries an offset topology.
|
||
struct Side {
|
||
distance: f64,
|
||
curves: Vec<String>,
|
||
constraints: Vec<String>,
|
||
association: Option<String>,
|
||
param: Option<String>,
|
||
}
|
||
|
||
/// The signed distances an offset runs at. One side is the distance as given. `symmetric` is the
|
||
/// same distance each way, as `direction: symmetric` means for `solid.extrude`. `distance2` is an
|
||
/// independent distance the other way, as `distance2` means for `solid.extrude`. The primary side
|
||
/// comes first, so a one-sided caller reads the same first entry it always did.
|
||
fn offset_runs(d: f64, symmetric: bool, distance2: Option<f64>) -> Vec<f64> {
|
||
match (symmetric, distance2) {
|
||
(true, _) => vec![d.abs(), -d.abs()],
|
||
(_, Some(second)) => vec![d, -second * d.signum()],
|
||
_ => vec![d],
|
||
}
|
||
}
|
||
|
||
/// Build one side of a chain offset at the signed distance `d`. `order` and `sources` are the
|
||
/// walked chain, computed once before anything is created so a second side walks the same
|
||
/// curves rather than a pool the first side has already grown.
|
||
fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(String, bool)], closed: bool, convex: bool, d: f64) -> Result<Side> {
|
||
let mut made = Vec::new();
|
||
let mut cons = Vec::new();
|
||
if convex {
|
||
let created = sk.add_convex_offset(sources.to_vec(), d)?;
|
||
let roles = sk
|
||
.offsets
|
||
.iter()
|
||
.filter_map(|offset| offset.topology.as_ref())
|
||
.find(|topology| topology.id == created.association)
|
||
.map(|topology| topology.roles.clone())
|
||
.ok_or_else(|| bad(cmd, "new offset association is missing"))?;
|
||
for (source, role) in sources.iter().zip(&roles) {
|
||
if role.kind == solidcraft_sketch::OffsetRoleKind::Line {
|
||
let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?;
|
||
let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?;
|
||
cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?);
|
||
}
|
||
}
|
||
return Ok(Side { distance: d, curves: created.curves, constraints: cons, association: Some(created.association), param: None });
|
||
}
|
||
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();
|
||
let start_of = |p: &Piece| match p {
|
||
Piece::Line(a, _) => *a,
|
||
Piece::Arc(_, _, _, s, _, _) => *s,
|
||
Piece::Free(v) => v.first().copied().unwrap_or_default(),
|
||
};
|
||
let end_of = |p: &Piece| match p {
|
||
Piece::Line(_, b) => *b,
|
||
Piece::Arc(_, _, _, _, e, _) => *e,
|
||
Piece::Free(v) => v.last().copied().unwrap_or_default(),
|
||
};
|
||
// Junction points between consecutive pieces.
|
||
let joint = |x: &Piece, y: &Piece| -> Vec2 {
|
||
let (e, s0) = (end_of(x), start_of(y));
|
||
if e.dist(s0) < TOL * 10.0 {
|
||
return e;
|
||
}
|
||
let guess = (e + s0) * 0.5;
|
||
if matches!(x, Piece::Free(_)) || matches!(y, Piece::Free(_)) {
|
||
return guess;
|
||
}
|
||
intersections(&piece_shape(x), &piece_shape(y)).into_iter().min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))).unwrap_or(guess)
|
||
};
|
||
let (Some(head), Some(tail)) = (pieces.first(), pieces.last()) else {
|
||
return Err(bad(cmd, "no curves"));
|
||
};
|
||
let mut joints: Vec<Vec2> = Vec::new();
|
||
joints.push(if closed { joint(tail, head) } else { start_of(head) });
|
||
for k in 0..n.saturating_sub(1) {
|
||
let (Some(x), Some(y)) = (pieces.get(k), pieces.get(k + 1)) else {
|
||
return Err(bad(cmd, "the offset chain changed while it was built"));
|
||
};
|
||
joints.push(joint(x, y));
|
||
}
|
||
if !closed {
|
||
joints.push(end_of(tail));
|
||
}
|
||
// A straight piece whose joints come out reversed (or meet) was overrun by its
|
||
// neighbours: the offset is larger than the shape allows there.
|
||
for (k, pc) in pieces.iter().enumerate() {
|
||
if let Piece::Line(a, b) = pc {
|
||
let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied());
|
||
if let (Some(ja), Some(jb)) = (ja, jb)
|
||
&& (jb - ja).dot(*b - *a) <= TOL * a.dist(*b)
|
||
{
|
||
return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)"));
|
||
}
|
||
}
|
||
}
|
||
let pts: Vec<usize> = joints.iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
|
||
for (k, pc) in pieces.iter().enumerate() {
|
||
let (Some(pa), Some(pb)) = (pts.get(k).copied(), pts.get(if closed { (k + 1) % n } else { k + 1 }).copied()) else {
|
||
return Err(bad(cmd, "the offset chain changed while it was built"));
|
||
};
|
||
let orig = order.get(k).map(|(c, _)| *c).ok_or_else(|| bad(cmd, "the offset chain changed while it was built"))?;
|
||
match pc.clone() {
|
||
Piece::Free(v) => {
|
||
// A fit spline through a few of the samples, ending at the joints.
|
||
let step = (v.len() / 24).max(1);
|
||
let mut idx = vec![pa];
|
||
for q in v.iter().skip(step).step_by(step) {
|
||
if q.dist(*v.last().unwrap_or(q)) > 1e-6 {
|
||
idx.push(sk.add_point(*q, None)?);
|
||
}
|
||
}
|
||
idx.push(pb);
|
||
idx.dedup();
|
||
if idx.len() >= 2 {
|
||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?);
|
||
}
|
||
}
|
||
Piece::Line(..) => {
|
||
let l = sk.add_line_pts(pa, pb, None)?;
|
||
cons.push(add_c(sk, ConstraintKind::Parallel { a: orig, b: l })?);
|
||
made.push(l);
|
||
}
|
||
Piece::Arc(ci, cc, _, _, _, ccw) => {
|
||
let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default());
|
||
let a = if ccw {
|
||
sk.add_arc(cc, qa, qb, [Some(ci), Some(pa), Some(pb)], None)?
|
||
} else {
|
||
sk.add_arc(cc, qb, qa, [Some(ci), Some(pb), Some(pa)], None)?
|
||
};
|
||
made.push(a);
|
||
}
|
||
}
|
||
}
|
||
let outputs = ids_of(sk, &made);
|
||
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
|
||
sk.offsets.push(solidcraft_sketch::OffsetChain { sources: sources.to_vec(), outputs: outputs.clone(), distance: d, closed, topology: None });
|
||
sk.refresh_offsets()?;
|
||
}
|
||
Ok(Side { distance: d, curves: outputs, constraints: cons, association: None, param: None })
|
||
}
|
||
|
||
/// Numbers remain millimetres for programmatic callers; expressions use design units.
|
||
fn offset_length(s: &Session, p: &Value, key: &str, positive: bool) -> Result<(f64, String)> {
|
||
let cmd = "sketch.offset";
|
||
let expression = match num(p, key) {
|
||
Some(v) => format!("{v} mm"),
|
||
None => expr(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a length expression")))?,
|
||
};
|
||
let expression = s.doc.with_design_unit(&expression, solidcraft_doc::expr::Kind::Length);
|
||
let value = s.doc.eval(&expression, solidcraft_doc::expr::Kind::Length)?;
|
||
if !value.is_finite() || value.abs() <= 1e-9 || value.abs() > solidcraft_sketch::MAX_COORD || (positive && value < 0.0) {
|
||
return Err(bad(cmd, format!("`{key}` must be a {}length within the sketch bounds", if positive { "positive " } else { "finite nonzero " })));
|
||
}
|
||
Ok((value, expression))
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct OffsetOptions {
|
||
side: Option<Vec2>,
|
||
chain: bool,
|
||
distance: f64,
|
||
symmetric: bool,
|
||
distance2: Option<f64>,
|
||
}
|
||
|
||
/// Build every side of one connected source group. The pool is frozen before any output
|
||
/// is created, so later groups cannot accidentally walk a newly created offset.
|
||
fn offset_group(sk: &mut Sketch, ids: &[String], eligible: &std::collections::HashSet<String>, options: OffsetOptions) -> Result<Vec<Side>> {
|
||
let OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 } = options;
|
||
let cmd = "sketch.offset";
|
||
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"))?;
|
||
if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| {
|
||
topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output))
|
||
}) {
|
||
return Err(bad(
|
||
cmd,
|
||
"offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology",
|
||
));
|
||
}
|
||
let mut sides: Vec<Side> = Vec::new();
|
||
// Circles offset on their own, concentrically. Both sides are then a smaller circle and
|
||
// a larger one; the one toward the centre still has to have a size.
|
||
if let Some(CurveKind::Circle { c, r }) = sk.curves.get(first).map(|c| c.kind.clone()) {
|
||
let cc = sk.point(c).unwrap_or_default();
|
||
let source = sk.curves.get(first).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "offset source circle vanished"))?;
|
||
let grow = match side {
|
||
Some(q) => q.dist(cc) > r,
|
||
None => d0 < 0.0,
|
||
};
|
||
let radii: Vec<f64> = match (symmetric, distance2) {
|
||
(true, _) => vec![r - d0.abs(), r + d0.abs()],
|
||
(_, Some(second)) if grow => vec![r + d0.abs(), r - second],
|
||
(_, Some(second)) => vec![r - d0.abs(), r + second],
|
||
_ if grow => vec![r + d0.abs()],
|
||
_ => vec![r - d0.abs()],
|
||
};
|
||
for nr in radii {
|
||
if nr <= 1e-9 {
|
||
return Err(bad(cmd, "the offset circle would have no size"));
|
||
}
|
||
let ci = sk.add_circle(cc, nr, Some(c), None)?;
|
||
let outputs = ids_of(sk, &[ci]);
|
||
sk.offsets.push(solidcraft_sketch::OffsetChain {
|
||
sources: vec![(source.clone(), true)],
|
||
outputs: outputs.clone(),
|
||
distance: r - nr,
|
||
closed: true,
|
||
topology: None,
|
||
});
|
||
sides.push(Side { distance: r - nr, curves: outputs, constraints: Vec::new(), association: None, param: None });
|
||
}
|
||
return Ok(sides);
|
||
}
|
||
// The chain is walked once, before anything is created: a second side has to follow the
|
||
// same source curves, not a pool the first side has already added its output to.
|
||
let pool: Vec<usize> = if use_chain {
|
||
(0..sk.curves.len())
|
||
.filter(|i| {
|
||
sk.curves.get(*i).is_some_and(|c| eligible.contains(&c.id) && c.construction == sk.curves.get(first).is_some_and(|f| f.construction))
|
||
})
|
||
.collect()
|
||
} else {
|
||
sel.clone()
|
||
};
|
||
let (order, closed) = chain(sk, first, &pool);
|
||
// Side: toward `side` (compare both offsets' distance to it), else the sign.
|
||
let d = match side {
|
||
Some(q) => {
|
||
let dist = |dd: f64| {
|
||
offset_piece(sk, first, order.iter().find(|x| x.0 == first).is_none_or(|x| x.1), dd).map(|pc| piece_dist(&pc, q)).unwrap_or(f64::MAX)
|
||
};
|
||
if dist(d0.abs()) <= dist(-d0.abs()) { d0.abs() } else { -d0.abs() }
|
||
}
|
||
None => d0,
|
||
};
|
||
let runs = offset_runs(d, symmetric, distance2);
|
||
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.first().ok_or_else(|| bad(cmd, "no curves"))?;
|
||
for dd in runs {
|
||
let output = sk.add_freeform_offset(source, forward, dd)?;
|
||
sides.push(Side { distance: dd, curves: ids_of(sk, &[output]), constraints: Vec::new(), association: None, param: None });
|
||
}
|
||
return Ok(sides);
|
||
}
|
||
let sources = order
|
||
.iter()
|
||
.map(|(index, forward)| {
|
||
sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished"))
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
let convex = closed && sk.convex_offset_supported(&sources)?;
|
||
for dd in runs {
|
||
sides.push(offset_side(sk, cmd, &order, &sources, closed, convex, dd)?);
|
||
}
|
||
Ok(sides)
|
||
}
|
||
|
||
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.offset";
|
||
let ids = string_list(p, "curves");
|
||
if ids.is_empty() || ids.len() > 500 {
|
||
return Err(bad(cmd, "`curves` must list 1…500 curves"));
|
||
}
|
||
let (value, expression) = offset_length(s, p, "distance", false)?;
|
||
let expression = if value > 0.0 { expression } else { format!("-({expression})") };
|
||
let d0 = if bool_(p, "flip").unwrap_or(false) { -value } else { value };
|
||
let side = p.get("side").and_then(vec2);
|
||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||
// Both sides at once. The words keep the meaning `solid.extrude` gives them: `symmetric` is
|
||
// one distance applied each way, `distance2` is an independent distance the other way.
|
||
let symmetric = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
|
||
None | Some("positive") | Some("one_side") => bool_(p, "symmetric").unwrap_or(false),
|
||
Some("symmetric") => true,
|
||
Some("negative" | "reverse" | "flip") => {
|
||
return Err(bad(cmd, "an offset has no negative direction: give a negative `distance`, or a `side` to offset toward"));
|
||
}
|
||
Some(other) => return Err(bad(cmd, format!("unknown direction `{other}` (one_side, symmetric)"))),
|
||
};
|
||
let second = if p.get("distance2").is_some() { Some(offset_length(s, p, "distance2", true)?) } else { None };
|
||
let distance2 = second.as_ref().map(|(value, _)| *value);
|
||
let expression2 = second.map(|(_, expression)| expression);
|
||
if symmetric && distance2.is_some() {
|
||
return Err(bad(cmd, "give `distance2` or `symmetric`, not both"));
|
||
}
|
||
if symmetric && side.is_some() {
|
||
return Err(bad(cmd, "`symmetric` offsets both sides, so `side` has no side left to pick: drop one of them"));
|
||
}
|
||
let ((sides, param, param2), info) = edit(s, p, cmd, true, |sk, doc| {
|
||
sk.refresh_offsets()?;
|
||
let rep = solve_current(sk, doc)?;
|
||
if !rep.keeps_orientation() {
|
||
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
|
||
}
|
||
sk.refresh_offsets()?;
|
||
let eligible = sk.curves.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>();
|
||
let param = doc.new_model_param(&expression, "mm");
|
||
let param2 = expression2.as_ref().map(|e| doc.new_model_param(e, "mm"));
|
||
let mut pending = ids.clone();
|
||
let mut sides = Vec::new();
|
||
while !pending.is_empty() {
|
||
let mut group = offset_group(sk, &pending, &eligible, OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 })?;
|
||
let output = group.first().and_then(|side| side.curves.first()).ok_or_else(|| bad(cmd, "offset produced no curves"))?;
|
||
let sources = sk
|
||
.offsets
|
||
.iter()
|
||
.find(|o| o.owns_output(output))
|
||
.map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>())
|
||
.or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()]))
|
||
.ok_or_else(|| bad(cmd, "offset association is missing"))?;
|
||
for (index, side) in group.iter_mut().enumerate() {
|
||
let name = if index == 1 { param2.as_ref().unwrap_or(¶m) } else { ¶m };
|
||
let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?;
|
||
side.constraints.push(sk.add_constraint(
|
||
ConstraintKind::OffsetDistance { output: output.clone(), value: side.distance.abs(), reverse: side.distance < 0.0 },
|
||
Some(name.clone()),
|
||
)?);
|
||
side.param = Some(name.clone());
|
||
}
|
||
let before = pending.len();
|
||
pending.retain(|id| !sources.contains(id));
|
||
if pending.len() == before {
|
||
return Err(bad(cmd, "offset did not consume its source selection"));
|
||
}
|
||
sides.extend(group);
|
||
}
|
||
Ok((sides, param, param2))
|
||
})?;
|
||
let curves: Vec<&String> = sides.iter().flat_map(|side| side.curves.iter()).collect();
|
||
let constraints: Vec<&String> = sides.iter().flat_map(|side| side.constraints.iter()).collect();
|
||
let each: Vec<Value> = sides
|
||
.iter()
|
||
.map(|side| {
|
||
let mut entry = json!({"distance": side.distance, "curves": side.curves, "param": side.param});
|
||
if let Some(association) = &side.association {
|
||
entry["association"] = json!(association);
|
||
}
|
||
entry
|
||
})
|
||
.collect();
|
||
let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param});
|
||
if let Some(param2) = param2 {
|
||
response["param2"] = json!(param2);
|
||
}
|
||
// One side keeps the single `association` it always returned. Two sides have one each, in
|
||
// `sides`, so there is no single association left to name.
|
||
if let [only] = sides.as_slice()
|
||
&& let Some(association) = &only.association
|
||
{
|
||
response["association"] = json!(association);
|
||
}
|
||
Ok(response)
|
||
}
|
||
|
||
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.offset.break";
|
||
let sketch = super::sketch::target_sketch(s, p, cmd)?;
|
||
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);
|
||
let topology = sk
|
||
.offsets
|
||
.iter()
|
||
.filter_map(|record| record.topology.as_ref())
|
||
.any(|record| record.id == output || record.roles.iter().any(|role| role.output == output));
|
||
let analytic = sk.offsets.iter().any(|o| o.outputs.iter().any(|id| id == output));
|
||
if record.is_none() && !topology && !analytic {
|
||
return Err(bad(cmd, "curve is not an offset output"));
|
||
}
|
||
// 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 rep = solve_current(sk, doc)?;
|
||
if !rep.keeps_orientation() {
|
||
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
|
||
}
|
||
sk.validate_periodic_splines()?;
|
||
if let Some(association) = sk
|
||
.offsets
|
||
.iter()
|
||
.filter_map(|record| record.topology.as_ref())
|
||
.find(|record| record.id == output || record.roles.iter().any(|role| role.output == output))
|
||
.map(|record| record.id.clone())
|
||
{
|
||
freeze_offset_profiles(doc, sketch, sk, &association)?;
|
||
}
|
||
if let Some(curves) = sk.break_offset_topology(output)? {
|
||
return Ok((output.to_string(), Some(curves)));
|
||
}
|
||
let ordinary = sk.offsets.iter().position(|o| o.topology.is_none() && o.outputs.iter().any(|id| id == output));
|
||
if let Some(index) = ordinary {
|
||
let record = sk.offsets.remove(index);
|
||
let centres = record
|
||
.outputs
|
||
.iter()
|
||
.filter_map(|id| sk.curve_index(id).and_then(|i| sk.curves.get(i)))
|
||
.filter_map(|curve| match curve.kind {
|
||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c),
|
||
_ => None,
|
||
})
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
for centre in centres {
|
||
let position = sk.point(centre).ok_or_else(|| bad(cmd, "missing offset centre"))?;
|
||
let detached = sk.add_point(position, None)?;
|
||
for curve in &mut sk.curves {
|
||
if record.outputs.contains(&curve.id) {
|
||
match &mut curve.kind {
|
||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } if *c == centre => *c = detached,
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sk.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output, .. } if record.outputs.contains(output)));
|
||
for id in &record.outputs {
|
||
if let Some(curve) = sk.curve_index(id).and_then(|i| sk.curves.get(i)) {
|
||
let points = curve.kind.point_ids();
|
||
for i in points {
|
||
if i != 0
|
||
&& sk.curves.iter().all(|c| !c.kind.uses(i) || record.outputs.contains(&c.id))
|
||
&& let Some(p) = sk.points.get_mut(i)
|
||
{
|
||
p.fixed = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return Ok((output.to_string(), Some(record.outputs)));
|
||
}
|
||
let record = record.ok_or_else(|| bad(cmd, "offset association changed while materializing its current geometry"))?;
|
||
sk.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output: id, .. } if id == output));
|
||
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(), None))
|
||
})?;
|
||
s.doc_mut().prune_model_params();
|
||
let mut response = json!({"curve":out.0,"sketch":info});
|
||
if let Some(curves) = out.1 {
|
||
response["curves"] = json!(curves);
|
||
}
|
||
Ok(response)
|
||
}
|
||
|
||
/// A broken logical boundary becomes ordinary geometry. Keep downstream features
|
||
/// on that exact current boundary while other associations retain their full roles.
|
||
fn freeze_offset_profiles(doc: &mut solidcraft_doc::Document, sketch: u64, boundary: &Sketch, association: &str) -> Result<()> {
|
||
let cmd = "sketch.offset.break";
|
||
let mut groups = std::collections::BTreeMap::new();
|
||
let mut work = 0usize;
|
||
for offset in &boundary.offsets {
|
||
let Some(topology) = &offset.topology else { continue };
|
||
work = work.saturating_add(topology.roles.len());
|
||
if groups.len() >= 128 || topology.roles.len() > 512 || work > 1_000_000 {
|
||
return Err(bad(cmd, "too many offset roles to freeze safely"));
|
||
}
|
||
let reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||
if groups.insert(topology.id.clone(), (offset.outputs.clone(), reserved)).is_some() {
|
||
return Err(bad(cmd, "ambiguous offset association identity"));
|
||
}
|
||
}
|
||
let (active, reserved) = groups.get(association).ok_or_else(|| bad(cmd, "offset association disappeared before freezing"))?;
|
||
let mut reserved_sorted = reserved.clone();
|
||
reserved_sorted.sort();
|
||
for feature in &mut doc.features {
|
||
for (source, selection) in feature.kind.profile_sels_mut() {
|
||
if source != sketch {
|
||
continue;
|
||
}
|
||
match selection {
|
||
ProfileSel::OffsetBoundaries { associations } if associations.iter().any(|id| id == association) => {
|
||
let mut loops = Vec::with_capacity(associations.len());
|
||
for id in associations.iter() {
|
||
let (current, all_roles) = groups.get(id).ok_or_else(|| bad(cmd, format!("cannot freeze missing offset boundary `{id}`")))?;
|
||
let roles = if id == association { current } else { all_roles };
|
||
work = work.saturating_add(roles.len());
|
||
if work > 1_000_000 {
|
||
return Err(bad(cmd, "too many downstream offset references to freeze safely"));
|
||
}
|
||
loops.push(roles.clone());
|
||
}
|
||
*selection = ProfileSel::Curves { loops };
|
||
}
|
||
ProfileSel::Curves { loops } => {
|
||
for ids in loops {
|
||
work = work.saturating_add(ids.len());
|
||
if work > 1_000_000 {
|
||
return Err(bad(cmd, "too many downstream curve references to freeze safely"));
|
||
}
|
||
if ids.len() == reserved_sorted.len() {
|
||
let mut sorted = ids.clone();
|
||
sorted.sort();
|
||
if sorted == reserved_sorted {
|
||
*ids = active.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Copies: mirror, patterns, move, scale
|
||
|
||
/// Curves and lone points named in `entities`.
|
||
fn entities(sk: &Sketch, p: &Value, cmd: &str) -> Result<(Vec<usize>, Vec<usize>)> {
|
||
let ids = string_list(p, "entities");
|
||
if ids.is_empty() {
|
||
return Err(bad(cmd, "`entities` must list sketch curves or points"));
|
||
}
|
||
let (mut cs, mut ps) = (Vec::new(), Vec::new());
|
||
for id in &ids {
|
||
if let Some(c) = sk.curve_index(id) {
|
||
cs.push(c);
|
||
} else if let Some(q) = sk.resolve_point(id) {
|
||
ps.push(q);
|
||
} else {
|
||
return Err(bad(cmd, format!("unknown sketch entity `{id}`")));
|
||
}
|
||
}
|
||
Ok((cs, ps))
|
||
}
|
||
|
||
/// Points used by curves plus lone points, in a stable order.
|
||
fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec<usize> {
|
||
let mut v: Vec<usize> = Vec::new();
|
||
for c in cs {
|
||
if let Some(k) = sk.curves.get(*c).map(|c| &c.kind) {
|
||
v.extend(k.point_ids());
|
||
}
|
||
}
|
||
v.extend(ps);
|
||
let mut out = Vec::new();
|
||
for q in v {
|
||
if !out.contains(&q) {
|
||
out.push(q);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Copy curves and points through `f`. `flip` reverses arcs (mirroring). `keep(p)`: reuse the
|
||
/// original point instead of copying it (points on a mirror line). Returns the new curves and
|
||
/// the point map (original → copy).
|
||
fn copy_through(
|
||
sk: &mut Sketch,
|
||
cs: &[usize],
|
||
ps: &[usize],
|
||
f: &dyn Fn(Vec2) -> Vec2,
|
||
flip: bool,
|
||
keep: &dyn Fn(Vec2) -> bool,
|
||
scale_r: f64,
|
||
) -> Result<(Vec<usize>, Vec<(usize, usize)>)> {
|
||
let pts = involved_points(sk, cs, ps);
|
||
let mut map: Vec<(usize, usize)> = Vec::new();
|
||
for q in &pts {
|
||
let pos = sk.point(*q).unwrap_or_default();
|
||
let n = if keep(pos) { *q } else { sk.add_point(f(pos), None)? };
|
||
map.push((*q, n));
|
||
}
|
||
let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q);
|
||
let mut made = Vec::new();
|
||
for c in cs {
|
||
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
|
||
let mut kind = cu.kind.clone();
|
||
kind.map_points(&m);
|
||
let (kind, prefix) = match cu.kind {
|
||
CurveKind::Ellipse { r, .. } => {
|
||
if let CurveKind::Ellipse { r: slot, .. } = &mut kind {
|
||
*slot = r * scale_r;
|
||
}
|
||
(kind, "e")
|
||
}
|
||
CurveKind::Spline { .. } => (kind, "s"),
|
||
CurveKind::Conic { .. } => (kind, "k"),
|
||
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, "l"),
|
||
CurveKind::Circle { c, r } => (CurveKind::Circle { c: m(c), r: r * scale_r }, "c"),
|
||
CurveKind::Arc { c, a, b } => {
|
||
if flip {
|
||
(CurveKind::Arc { c: m(c), a: m(b), b: m(a) }, "a")
|
||
} else {
|
||
(CurveKind::Arc { c: m(c), a: m(a), b: m(b) }, "a")
|
||
}
|
||
}
|
||
};
|
||
let id = sk.fresh(prefix)?;
|
||
sk.curves.push(Curve {
|
||
id,
|
||
kind,
|
||
construction: cu.construction,
|
||
reversed: if flip { !cu.reversed } else { cu.reversed },
|
||
link: None,
|
||
centerline: false,
|
||
fixed: false,
|
||
});
|
||
made.push(sk.curves.len() - 1);
|
||
}
|
||
Ok((made, map))
|
||
}
|
||
|
||
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.mirror";
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (cs, ps) = entities(sk, p, cmd)?;
|
||
let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?;
|
||
let l = sk.curve_index(lid).ok_or_else(|| bad(cmd, format!("unknown curve `{lid}`")))?;
|
||
let Some(Shape::Line { a, b }) = sk.shape(l) else { return Err(bad(cmd, "the mirror must be a line")) };
|
||
let u = (b - a).normalized().ok_or_else(|| bad(cmd, "degenerate mirror line"))?;
|
||
let refl = move |q: Vec2| {
|
||
let foot = a + u * (q - a).dot(u);
|
||
foot * 2.0 - q
|
||
};
|
||
let on_line = move |q: Vec2| u.cross(q - a).abs() < TOL * 10.0;
|
||
let cs: Vec<usize> = cs.into_iter().filter(|c| *c != l).collect();
|
||
let (made, map) = copy_through(sk, &cs, &ps, &refl, true, &on_line, 1.0)?;
|
||
let mut cons = Vec::new();
|
||
for (o, n) in &map {
|
||
if o != n {
|
||
cons.push(add_c(sk, ConstraintKind::Symmetric { p: *o, q: *n, l })?);
|
||
}
|
||
}
|
||
for (o, n) in cs.iter().zip(&made) {
|
||
if matches!(sk.curves.get(*o).map(|c| &c.kind), Some(CurveKind::Circle { .. })) {
|
||
cons.push(add_c(sk, ConstraintKind::Equal { a: *o, b: *n })?);
|
||
}
|
||
}
|
||
Ok((ids_of(sk, &made), cons))
|
||
})?;
|
||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||
}
|
||
|
||
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||
if !(1.0..=MAX_COPIES as f64).contains(&n) {
|
||
return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}")));
|
||
}
|
||
Ok(n as usize)
|
||
}
|
||
|
||
fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result<Vec2> {
|
||
match p.get(k) {
|
||
Some(Value::String(r)) => sk.resolve_point(r).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, format!("unknown point `{r}`"))),
|
||
Some(v) => vec2(v).ok_or_else(|| bad(cmd, format!("`{k}` must be [x, y] or a point id"))),
|
||
None => Err(bad(cmd, format!("missing `{k}`"))),
|
||
}
|
||
}
|
||
|
||
fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
|
||
move |q: Vec2| {
|
||
let d = q - c;
|
||
let (s, co) = ang.sin_cos();
|
||
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||
}
|
||
}
|
||
|
||
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.pattern.circular";
|
||
let n = count(p, "count", cmd)?;
|
||
let total = num(p, "angle").unwrap_or(360.0);
|
||
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
|
||
return Err(bad(cmd, "`angle` must be in (0, 360]"));
|
||
}
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (cs, ps) = entities(sk, p, cmd)?;
|
||
let c = point_or_ref(sk, p, "center", cmd)?;
|
||
let full = (total.abs() - 360.0).abs() < 1e-9;
|
||
let step = if full { total / n as f64 } else { total / (n.max(2) - 1) as f64 };
|
||
let mut made = Vec::new();
|
||
for k in 1..n {
|
||
let f = rotate(c, (step * k as f64).to_radians());
|
||
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||
made.extend(m);
|
||
}
|
||
Ok(ids_of(sk, &made))
|
||
})?;
|
||
Ok(json!({"curves": out, "sketch": info}))
|
||
}
|
||
|
||
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.pattern.rectangular";
|
||
let n1 = count(p, "count", cmd)?;
|
||
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
|
||
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
|
||
let s2 = num(p, "spacing2").unwrap_or(s1);
|
||
if n1 * n2 > MAX_COPIES {
|
||
return Err(bad(cmd, "too many copies"));
|
||
}
|
||
let d1 = p.get("dir").and_then(vec2).and_then(Vec2::normalized).unwrap_or(Vec2::X);
|
||
let d2 = d1.perp();
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (cs, ps) = entities(sk, p, cmd)?;
|
||
let mut made = Vec::new();
|
||
for i in 0..n1 {
|
||
for j in 0..n2 {
|
||
if i == 0 && j == 0 {
|
||
continue;
|
||
}
|
||
let off = d1 * (s1 * i as f64) + d2 * (s2 * j as f64);
|
||
let (m, _) = copy_through(sk, &cs, &ps, &move |q| q + off, false, &|_| false, 1.0)?;
|
||
made.extend(m);
|
||
}
|
||
}
|
||
Ok(ids_of(sk, &made))
|
||
})?;
|
||
Ok(json!({"curves": out, "sketch": info}))
|
||
}
|
||
|
||
/// Move the points of the entities (in place) through `f`; circle radii scale by `k`.
|
||
fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Vec2) -> Vec2, k: f64, cmd: &str) -> Result<()> {
|
||
let pts = involved_points(sk, cs, ps);
|
||
if pts.iter().any(|q| sk.points.get(*q).is_some_and(|x| x.fixed || x.link.is_some())) {
|
||
return Err(bad(cmd, "fixed or projected geometry cannot move"));
|
||
}
|
||
for q in pts {
|
||
if let Some(x) = sk.points.get_mut(q) {
|
||
x.pos = f(x.pos);
|
||
}
|
||
}
|
||
for c in cs {
|
||
if let Some(Curve { kind: CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, .. }) = sk.curves.get_mut(*c) {
|
||
*r *= k;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.move";
|
||
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
|
||
let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians();
|
||
let copy = bool_(p, "copy").unwrap_or(false);
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (mut cs, mut ps) = entities(sk, p, cmd)?;
|
||
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
|
||
let rot = rotate(c, ang);
|
||
let f = move |q: Vec2| rot(q) + t;
|
||
let mut links = Vec::new();
|
||
for entity in string_list(p, "entities") {
|
||
if let Some(link) = super::sketch_create::text_link(sk, &entity)
|
||
&& !links.iter().any(|old| old == link)
|
||
{
|
||
links.push(link.to_owned());
|
||
}
|
||
}
|
||
cs.retain(|&i| !sk.curves.get(i).and_then(|c| c.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||
ps.retain(|&i| !sk.points.get(i).and_then(|p| p.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||
let mut text_curves = Vec::new();
|
||
for link in links {
|
||
text_curves.extend(super::sketch_create::move_text(sk, &link, t, ang, c, copy)?);
|
||
}
|
||
if copy {
|
||
let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||
m.extend(text_curves);
|
||
return Ok(ids_of(sk, &m));
|
||
}
|
||
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
|
||
cs.extend(text_curves);
|
||
Ok(ids_of(sk, &cs))
|
||
})?;
|
||
Ok(json!({"curves": out, "sketch": info}))
|
||
}
|
||
|
||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "sketch.scale";
|
||
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
|
||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||
let (cs, ps) = entities(sk, p, cmd)?;
|
||
let b = if p.get("base").is_some() { point_or_ref(sk, p, "base", cmd)? } else { Vec2::ZERO };
|
||
let f = move |q: Vec2| b + (q - b) * k;
|
||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||
Ok(ids_of(sk, &cs))
|
||
})?;
|
||
Ok(json!({"curves": out, "sketch": info}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "sketch_modify_tests.rs"]
|
||
mod tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "sketch_robust_tests.rs"]
|
||
mod robust_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "sketch_corner_tests.rs"]
|
||
mod corner_tests;
|