Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 5ae80124cb refactor: split complex matchers, scanners and solver setup into helpers
Pure extraction, no behaviour change: same order of side effects, same
budget charges, same early returns and error values. Clears
cognitive-complexity findings on glob_inner, the PDF test ops scanner,
snap_ends, the loops inquiry run and build_budgeted (S3776).
2026-10-25 21:33:51 -04:00
10 changed files with 414 additions and 411 deletions
+51 -40
View File
@@ -358,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
groups.into_values().collect()
}
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
/// less, angles and ratios by size², and contact parameters move freely.
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
for (j, f) in slot_scales(model, s) {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
weights
}
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
/// points is what is minimised.
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
}
}
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
@@ -396,46 +446,7 @@ pub(crate) fn build_budgeted(
if built.is_empty() {
continue;
}
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
// Angles and ratios: weight by size² so the movement of points is what is minimised.
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
let scaled: Vec<(usize, f64)> = match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
};
for (j, f) in scaled {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
let weights = solver_weights(&model, opts);
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, held, tol });
+65 -72
View File
@@ -191,77 +191,6 @@ impl Model {
Self::build_budgeted(d, handles, &mut members_left)
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
return None;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
return None;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => return None,
};
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
@@ -271,7 +200,71 @@ impl Model {
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
let shape = match &e.kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
continue;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
continue;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => continue,
};
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
+33 -45
View File
@@ -441,49 +441,6 @@ impl DimStyle {
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
}
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
/// the text or number can be read as it.
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
use serde_json::Value;
match (old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
}
}
/// Whether `coerced` is outside the range its `field` can take.
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
let x = coerced.as_f64();
match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
}
}
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values.
@@ -501,7 +458,29 @@ impl DimStyle {
rejected.push(k.clone());
continue;
};
let coerced = Self::coerce_value(&old, v, field);
let coerced = match (&old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
};
// Integers stored in u8 fields: round floats.
let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
@@ -523,7 +502,16 @@ impl DimStyle {
_ => coerced,
};
// Sizes that can't be negative (text height and tolerance scale: not zero either).
let out_of_range = Self::out_of_range(field, &coerced);
let x = coerced.as_f64();
let out_of_range = match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
};
if out_of_range {
rejected.push(k.clone());
continue;
+20 -26
View File
@@ -1585,10 +1585,25 @@ fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f
k
}
/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with
/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`.
fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> {
Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("rectang", p, "p1")?;
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
let centered = bool_or(p, "center", false);
let rot = f64_or(p, "rotation", 0.0).to_radians();
let ch = p
.get("chamfer")
.and_then(Value::as_array)
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
.unwrap_or((0.0, 0.0));
if ch.0 < 0.0 || ch.1 < 0.0 {
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
}
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
let (sx, sy) = match point_param(p, "p2") {
@@ -1613,28 +1628,7 @@ fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)>
let loc = (b - a).rotate(-rot);
// In centre mode the picked point is one corner, mirrored across the centre.
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
})
}
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("rectang", p, "p1")?;
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
let centered = bool_or(p, "center", false);
let rot = f64_or(p, "rotation", 0.0).to_radians();
let ch = p
.get("chamfer")
.and_then(Value::as_array)
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
.unwrap_or((0.0, 0.0));
if ch.0 < 0.0 || ch.1 < 0.0 {
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
}
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
let (w, h) = rect_size(p, a, rot, centered)?;
};
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
return Err(bad("rectang", "rectangle has no area"));
}
+81 -63
View File
@@ -117,21 +117,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
// Endpoints closer than the tolerance meet. Transitive components of three or more ends are
// branch junctions; a component of exactly two is a simple joint.
let mut parent: Vec<usize> = (0..n).collect();
for i in 0..n {
let Some(a) = ends.get(i) else { continue };
for j in (i + 1)..n {
let Some(b) = ends.get(j) else { continue };
if a.at.dist(b.at) <= tol {
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
if ra != rb
&& let Some(slot) = parent.get_mut(ra)
{
*slot = rb;
}
}
}
}
let mut parent = merge_ends(&ends, tol);
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..n {
let root = find(&mut parent, i);
@@ -140,54 +126,8 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
// Junctions are reported and their pieces left out of the chains: not resolved.
let mut excluded = vec![false; pieces.len()];
let mut junctions: Vec<Value> = Vec::new();
let mut joints: Vec<(usize, usize)> = Vec::new();
for group in groups.values() {
match group.as_slice() {
&[a, b] => joints.push((a, b)),
[_, _, ..] => {
let scale = group.len() as f64;
let mut at = Vec2::ZERO;
let mut members: Vec<Handle> = Vec::new();
for &e in group {
let Some(end) = ends.get(e) else { continue };
at += end.at / scale;
let Some(piece) = pieces.get(end.piece) else { continue };
if !members.contains(&piece.handle) {
members.push(piece.handle);
}
if let Some(slot) = excluded.get_mut(end.piece) {
*slot = true;
}
}
junctions.push(json!({
"at": [at.x, at.y],
"ends": group.len(),
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
}));
}
_ => {}
}
}
// A joint links the two ends for the walk. A joint into an excluded piece is not a link, and
// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
let mut link: Vec<Option<usize>> = vec![None; n];
for (a, b) in joints {
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
continue;
}
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = link.get_mut(a) {
*slot = Some(b);
}
if let Some(slot) = link.get_mut(b) {
*slot = Some(a);
}
}
let (joints, junctions) = sort_groups(&groups, &ends, &pieces, &mut excluded);
let link = joint_links(joints, &ends, &pieces, &excluded);
let mut visited = vec![false; pieces.len()];
let mut loops: Vec<Value> = Vec::new();
@@ -225,6 +165,84 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A report with nothing analyzed: an empty drawing, or one past the pair cap.
/// Union-find over the ends: ends closer than `tol` share a root. Returns each end's parent.
fn merge_ends(ends: &[End], tol: f64) -> Vec<usize> {
let n = ends.len();
let mut parent: Vec<usize> = (0..n).collect();
for i in 0..n {
let Some(a) = ends.get(i) else { continue };
for j in (i + 1)..n {
let Some(b) = ends.get(j) else { continue };
if a.at.dist(b.at) <= tol {
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
if ra != rb
&& let Some(slot) = parent.get_mut(ra)
{
*slot = rb;
}
}
}
}
parent
}
/// Each group of two ends is a joint (end indices); each of three or more is a junction, reported
/// with its pieces marked `excluded`.
fn sort_groups(groups: &BTreeMap<usize, Vec<usize>>, ends: &[End], pieces: &[Piece], excluded: &mut [bool]) -> (Vec<(usize, usize)>, Vec<Value>) {
let mut joints: Vec<(usize, usize)> = Vec::new();
let mut junctions: Vec<Value> = Vec::new();
for group in groups.values() {
match group.as_slice() {
&[a, b] => joints.push((a, b)),
[_, _, ..] => {
let scale = group.len() as f64;
let mut at = Vec2::ZERO;
let mut members: Vec<Handle> = Vec::new();
for &e in group {
let Some(end) = ends.get(e) else { continue };
at += end.at / scale;
let Some(piece) = pieces.get(end.piece) else { continue };
if !members.contains(&piece.handle) {
members.push(piece.handle);
}
if let Some(slot) = excluded.get_mut(end.piece) {
*slot = true;
}
}
junctions.push(json!({
"at": [at.x, at.y],
"ends": group.len(),
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
}));
}
_ => {}
}
}
(joints, junctions)
}
/// The link each end takes from the joints. A joint into an excluded piece is not a link, and
/// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
fn joint_links(joints: Vec<(usize, usize)>, ends: &[End], pieces: &[Piece], excluded: &[bool]) -> Vec<Option<usize>> {
let mut link: Vec<Option<usize>> = vec![None; ends.len()];
for (a, b) in joints {
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
continue;
}
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = link.get_mut(a) {
*slot = Some(b);
}
if let Some(slot) = link.get_mut(b) {
*slot = Some(a);
}
}
link
}
fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value {
json!({
"schema": 1,
+43 -36
View File
@@ -785,6 +785,48 @@ fn glob_cached(
Ok(matched)
}
/// For the bracket class opening at `pat[p]`, whether `text[t]` is in it: the index of its closing
/// `]` when it is, `None` when it is not or the class is never closed.
fn class_end(text: &[char], pat: &[char], p: usize, t: usize, budget: &mut u64) -> Result<Option<usize>, ()> {
let mut close = p;
while let Some(c) = pat.get(close) {
charge_match(budget)?;
if *c == ']' {
break;
}
close += 1;
}
if close == pat.len() {
return Ok(None);
}
let Some(&c) = text.get(t) else { return Ok(None) };
let set = &pat[p + 1..close];
let (neg, set) = match set.split_first() {
Some(('~', r)) if !r.is_empty() => (true, r),
_ => (false, set),
};
let mut hit = false;
let mut i = 0;
while i < set.len() {
charge_match(budget)?;
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
if set[i] <= c && c <= set[i + 2] {
hit = true;
}
i += 3;
} else {
if set[i] == c {
hit = true;
}
i += 1;
}
}
if hit == neg {
return Ok(None);
}
Ok(Some(close))
}
/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits.
fn glob_inner(
text: &[char],
@@ -822,42 +864,7 @@ fn glob_inner(
p += 2;
}
'[' => {
let mut close = p;
while let Some(c) = pat.get(close) {
charge_match(budget)?;
if *c == ']' {
break;
}
close += 1;
}
if close == pat.len() {
return Ok(false);
}
let Some(&c) = text.get(t) else { return Ok(false) };
let set = &pat[p + 1..close];
let (neg, set) = match set.split_first() {
Some(('~', r)) if !r.is_empty() => (true, r),
_ => (false, set),
};
let mut hit = false;
let mut i = 0;
while i < set.len() {
charge_match(budget)?;
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
if set[i] <= c && c <= set[i + 2] {
hit = true;
}
i += 3;
} else {
if set[i] == c {
hit = true;
}
i += 1;
}
}
if hit == neg {
return Ok(false);
}
let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) };
t += 1;
p = close + 1;
}
+38 -65
View File
@@ -236,46 +236,6 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
if !(r.is_finite() && r >= 0.0) {
return Vec::new();
}
let Some(win) = search_window(c1, c2, near) else { return Vec::new() };
let size = win.width().max(win.height()).max(r).max(1e-9);
let win = win.expand(2.0 * r + 0.25 * size);
let tol = 1e-9 * size.max(1.0);
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
let mut out: Vec<CornerFit> = Vec::new();
let mut work = 16_000_000usize;
for (s1, s2) in signs {
let (d1, d2) = (s1 * r, s2 * r);
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
let search = TanSearch { c1, r1: &r1, d1, c2, r2: &r2, d2, tol };
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
// not consume all candidate slots. A separate request-wide budget bounds actual work.
for a in &o1 {
for b in &o2 {
if !refine_pair(&search, a, b, &mut work, &mut out) {
return out;
}
}
}
}
out
}
/// The two curves of one tangent search for one sign choice: their ranges and offsets, and the
/// tolerance.
struct TanSearch<'a> {
c1: &'a TanCurve,
r1: &'a Range,
d1: f64,
c2: &'a TanCurve,
r2: &'a Range,
d2: f64,
tol: f64,
}
/// The window the tangent search looks in: the pick points and the extents of the curves that
/// are not lines. `None` when it is empty or not finite.
fn search_window(c1: &TanCurve, c2: &TanCurve, near: &[Vec2]) -> Option<Bounds2> {
let mut win = Bounds2::from_points(near.iter().copied().filter(|p| p.is_finite()));
for c in [c1, c2] {
match c {
@@ -291,34 +251,47 @@ fn search_window(c1: &TanCurve, c2: &TanCurve, near: &[Vec2]) -> Option<Bounds2>
}
}
if win.is_empty() || !(win.width().is_finite() && win.height().is_finite()) {
return None;
return Vec::new();
}
Some(win)
}
/// Refine the crossings of the samples `a` and `b` into circles, adding the distinct ones to
/// `out`. `false` when the work budget or the solution cap ends the search.
fn refine_pair(search: &TanSearch, a: &[(f64, Vec2)], b: &[(f64, Vec2)], work: &mut usize, out: &mut Vec<CornerFit>) -> bool {
let mut seeds = Vec::new();
crossings(a, b, &mut seeds, work);
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
for seed in seeds {
// Newton refinement has a fixed iteration cap and several curve evaluations.
let Some(left) = work.checked_sub(128) else { return false };
*work = left;
let TanSearch { c1, r1, d1, c2, r2, d2, tol } = *search;
let Some((t1, t2)) = refine(c1, r1, d1, c2, r2, d2, seed, tol) else { continue };
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
if !out.iter().any(near_dup) {
out.push(CornerFit { t1, t2, p1, p2, center });
}
if out.len() >= MAX_SOLUTIONS {
return false;
let size = win.width().max(win.height()).max(r).max(1e-9);
let win = win.expand(2.0 * r + 0.25 * size);
let tol = 1e-9 * size.max(1.0);
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
let mut out: Vec<CornerFit> = Vec::new();
let mut work = 16_000_000usize;
for (s1, s2) in signs {
let (d1, d2) = (s1 * r, s2 * r);
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
// not consume all candidate slots. A separate request-wide budget bounds actual work.
for a in &o1 {
for b in &o2 {
let mut seeds = Vec::new();
crossings(a, b, &mut seeds, &mut work);
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
for seed in seeds {
// Newton refinement has a fixed iteration cap and several curve evaluations.
let Some(left) = work.checked_sub(128) else { return out };
work = left;
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
if !out.iter().any(near_dup) {
out.push(CornerFit { t1, t2, p1, p2, center });
}
if out.len() >= MAX_SOLUTIONS {
return out;
}
}
if work == 0 {
return out;
}
}
}
}
*work != 0
out
}
#[cfg(test)]
+40 -32
View File
@@ -447,6 +447,44 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
out
}
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
/// segment, parameter, point). `None` when the end touches a curve, or none is near. `Err` when
/// the budget of segment checks runs out.
fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, budget: &mut usize) -> Result<Option<(usize, usize, f64, Vec2)>, ()> {
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
let mut touching = false;
for (j, other) in polys.iter().enumerate() {
if j == i {
continue;
}
for (k, w) in other.windows(2).enumerate() {
if *budget == 0 {
return Err(());
}
*budget -= 1;
let [a, b] = w else { continue };
if !(a.is_finite() && b.is_finite())
|| e.x < a.x.min(b.x) - snap
|| e.x > a.x.max(b.x) + snap
|| e.y < a.y.min(b.y) - snap
|| e.y > a.y.max(b.y) + snap
{
continue;
}
let line = Line::new(*a, *b);
let t = line.param_of(e).clamp(0.0, 1.0);
let q = line.at(t);
let d = q.dist(e);
if d <= tol {
touching = true;
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
best = Some((d, j, k, t, q));
}
}
}
Ok(best.filter(|_| !touching).map(|(_, j, k, t, q)| (j, k, t, q)))
}
/// The curves with each open end that stops within `snap` of another curve moved onto that
/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened
/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve,
@@ -474,38 +512,8 @@ pub fn snap_ends(polys: &[Vec<Vec2>], snap: f64, tol: f64) -> Vec<Vec<Vec2>> {
if !e.is_finite() {
continue;
}
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
let mut touching = false;
for (j, other) in polys.iter().enumerate() {
if j == i {
continue;
}
for (k, w) in other.windows(2).enumerate() {
if budget == 0 {
break 'ends;
}
budget -= 1;
let [a, b] = w else { continue };
if !(a.is_finite() && b.is_finite())
|| e.x < a.x.min(b.x) - snap
|| e.x > a.x.max(b.x) + snap
|| e.y < a.y.min(b.y) - snap
|| e.y > a.y.max(b.y) + snap
{
continue;
}
let line = Line::new(*a, *b);
let t = line.param_of(e).clamp(0.0, 1.0);
let q = line.at(t);
let d = q.dist(e);
if d <= tol {
touching = true;
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
best = Some((d, j, k, t, q));
}
}
}
let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue };
let Ok(near) = nearest_curve(polys, i, e, snap, tol, &mut budget) else { break 'ends };
let Some((j, k, t, q)) = near else { continue };
let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue };
*slot = q;
let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1)));
+42 -31
View File
@@ -235,6 +235,46 @@ pub struct Op {
pub names: Vec<String>,
}
/// The index just past the string literal opening at `b[i]`, honouring escapes and nesting.
fn skip_string(b: &[char], mut i: usize) -> usize {
let mut depth = 0;
while i < b.len() {
match b[i] {
'\\' => i += 1,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
i + 1
}
/// The index just past the inline dictionary opening at `b[i]` (`<<`).
fn skip_dict(b: &[char], mut i: usize) -> usize {
let mut depth = 0;
while i < b.len() {
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
depth += 1;
i += 1;
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
depth -= 1;
i += 1;
if depth == 0 {
i += 1;
break;
}
}
i += 1;
}
i
}
/// The operators of content text in order, each with its numeric operands (other operands
/// are skipped).
pub fn ops(content: &str) -> Vec<Op> {
@@ -253,39 +293,10 @@ pub fn ops(content: &str) -> Vec<Op> {
i += 1;
}
} else if c == '(' {
let mut depth = 0;
while i < b.len() {
match b[i] {
'\\' => i += 1,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
i += 1;
i = skip_string(&b, i);
} else if c == '<' && b.get(i + 1) == Some(&'<') {
// An inline dictionary (marked-content properties): skip to its end.
let mut depth = 0;
while i < b.len() {
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
depth += 1;
i += 1;
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
depth -= 1;
i += 1;
if depth == 0 {
i += 1;
break;
}
}
i += 1;
}
i = skip_dict(&b, i);
} else if c == '<' {
while i < b.len() && b[i] != '>' {
i += 1;
+1 -1
View File
@@ -172,7 +172,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
let pick = app.services.pick_lisp.as_ref()?;
let dir = app.ui.last_lisp_dir.clone().unwrap_or_default();
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
if let Some(dir) = std::path::Path::new(&path).parent().and_then(|p| p.to_str()).filter(|d| !d.is_empty()) {
app.ui.last_lisp_dir = Some(dir.to_string());
}
if let Err(e) = app.session.start("appload") {