From 78c1653f500968896352cc0c4777b4e23d0e0aaf Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Fri, 23 Oct 2026 22:51:11 -0700 Subject: [PATCH] loops: implied closed loops from loose LINE and ARC chains (#1906) * loops: implied closed loops from loose LINE and ARC chains LOOPS is a read-only inquiry command that chains LINE and ARC endpoints meeting within a tolerance. Closed loops report their member handles, perimeter, area and winding (exact with arcs, like SHAPES); open chains report the endpoint gap, where it is and their length; branch junctions (three or more ends at one point) are reported but excluded from the chains rather than resolved. The tolerance defaults to 1e-9 x the extents diagonal and is an option. The quadratic endpoint pairing is capped (maxPairs, 1,000,000 by default) and reports capped/pairTestsNeeded instead of guessing from a partial pairing. Degenerate lines and arcs are counted under skipped; full-circle arcs close on their own. The command is read-only. Fixes #1437 * loops: cap maxPairs at 100,000,000 so a caller can't ask for unbounded work Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/inquiry.rs | 2 + crates/engine/src/cmd/inquiry/loops.rs | 439 +++++++++++++++++++++++++ crates/engine/tests/loops.rs | 287 ++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 1 + docs/mcp.md | 1 + 5 files changed, 730 insertions(+) create mode 100644 crates/engine/src/cmd/inquiry/loops.rs create mode 100644 crates/engine/tests/loops.rs diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 84abfade..60d4b29f 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -8,6 +8,7 @@ use super::*; use crate::units::{format_angle, format_distance}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +mod loops; mod regions; mod shapes; @@ -45,6 +46,7 @@ pub fn specs() -> Vec { .params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}") .noundo(), CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(), + CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(), CommandSpec::new("texts", "Query Texts", run_texts) .params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders") .noundo(), diff --git a/crates/engine/src/cmd/inquiry/loops.rs b/crates/engine/src/cmd/inquiry/loops.rs new file mode 100644 index 00000000..2a452151 --- /dev/null +++ b/crates/engine/src/cmd/inquiry/loops.rs @@ -0,0 +1,439 @@ +//! LOOPS: implied closed loops from loose LINE and ARC chains — perimeter, area and winding for +//! each closed loop, the gap and its location for each open chain, and branch junctions (three or +//! more ends meeting) reported but not resolved. Read-only. +//! +//! Boundaries are often drawn as separate LINEs and ARCs rather than one polyline, so an agent +//! cannot tell whether they form a closed loop or by how much a "closed" outline is open. The +//! endpoint chaining is quadratic in the number of endpoints; past `maxPairs` it reports `capped` +//! and analyzes nothing rather than guess from a partial pairing. + +use std::collections::{BTreeMap, BTreeSet}; + +use cadcraft_doc::{EntityKind, Handle}; +use cadcraft_geom::{Arc as GeoArc, EPS, Vec2}; +use serde_json::{Value, json}; + +use super::super::*; +use crate::{Result, Session}; + +const CMD: &str = "loops"; + +/// Endpoint pairs compared before the quadratic chaining gives up (reported as `capped`). +const DEFAULT_PAIR_LIMIT: usize = 1_000_000; +/// The most pairs a caller may ask for: a bounded amount of work however large the drawing. +const MAX_PAIR_LIMIT: usize = 100_000_000; +/// Loops returned, and open chains and junctions kept, by default. +const DEFAULT_LIMIT: usize = 200; +/// The smallest default tolerance, for a drawing at the origin. +const MIN_TOLERANCE: f64 = 1e-12; + +pub const PARAMS: &str = "{tolerance?: positive number (default 1e-9 × the extents diagonal), maxPairs? (endpoint pairs compared, default 1000000, at most 100000000), limit? (default 200), offset?} → \ + {schema: 1, tolerance, pairTests, pairTestsNeeded, pairLimit, capped, count (all closed loops), returned, offset, truncated, \ + loops: [{members: [handle], perimeter, area, winding: ccw|cw}], \ + openChains: {count, truncated, chains: [{members, gap, at: [x, y], ends: [[x, y], [x, y]], length}]}, \ + junctions: {count, truncated, junctions: [{at: [x, y], ends, members}]}, skipped: {: {count, reason}}}: \ + chains LINE and ARC endpoints that meet within the tolerance; junctions (three or more ends meeting) are reported, not resolved"; + +/// Why a LINE or ARC cannot take part in a chain. +type Skipped = BTreeMap<&'static str, (usize, BTreeSet<&'static str>)>; + +/// One chainable entity, oriented as drawn (a line a→b, an arc CCW start→end). +struct Piece { + handle: Handle, + start: Vec2, + end: Vec2, + /// The arc, when the piece is one. + arc: Option, +} + +impl Piece { + fn at(&self, which: usize) -> Vec2 { + if which == 0 { self.start } else { self.end } + } + fn length(&self) -> f64 { + match self.arc { + Some(a) => a.radius * a.sweep(), + None => self.start.dist(self.end), + } + } +} + +/// An entity endpoint in the chain graph. Each piece has one at index `2 * piece` and one at +/// `2 * piece + 1`. +#[derive(Clone, Copy)] +struct End { + piece: usize, + at: Vec2, +} + +/// A piece traversed one way round a chain. +struct Traversed { + start: Vec2, + end: Vec2, + /// The arc and whether the traversal follows its CCW direction. + arc: Option<(GeoArc, bool)>, +} + +pub fn run(s: &mut Session, p: &Value) -> Result { + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let count = |k: &str, default: usize| -> Result { + given(k).map_or(Ok(default), |v| { + v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more"))) + }) + }; + let limit = count("limit", DEFAULT_LIMIT)?; + let offset = count("offset", 0)?; + let pair_limit = count("maxPairs", DEFAULT_PAIR_LIMIT)?.min(MAX_PAIR_LIMIT); + let tolerance = given("tolerance") + .map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number"))) + .transpose()?; + let s: &Session = s; + let st = s.state()?; + let d = &st.doc; + let tol = tolerance.unwrap_or_else(|| { + let ext = d.extents(&st.space); + if ext.is_empty() { + return MIN_TOLERANCE; + } + let diag = ext.min.dist(ext.max); + if diag.is_finite() { (diag * 1e-9).max(MIN_TOLERANCE) } else { MIN_TOLERANCE } + }); + let mut skipped: Skipped = BTreeMap::new(); + let Some(store) = d.space(&st.space) else { + return Ok(empty_report(offset, tol, 0, 0, pair_limit, false, skipped)); + }; + let pieces: Vec = store.iter().filter_map(|e| chainable(e.handle, &e.kind, &mut skipped)).collect(); + let mut ends: Vec = Vec::with_capacity(pieces.len().saturating_mul(2)); + for (i, piece) in pieces.iter().enumerate() { + ends.push(End { piece: i, at: piece.start }); + ends.push(End { piece: i, at: piece.end }); + } + let n = ends.len(); + let needed = n.saturating_mul(n.saturating_sub(1)) / 2; + if needed > pair_limit { + return Ok(empty_report(offset, tol, 0, needed, pair_limit, true, skipped)); + } + + // 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 = (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 groups: BTreeMap> = BTreeMap::new(); + for i in 0..n { + let root = find(&mut parent, i); + groups.entry(root).or_default().push(i); + } + + // Junctions are reported and their pieces left out of the chains: not resolved. + let mut excluded = vec![false; pieces.len()]; + let mut junctions: Vec = 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 = 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::>(), + })); + } + _ => {} + } + } + + // 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> = 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 mut visited = vec![false; pieces.len()]; + let mut loops: Vec = Vec::new(); + let mut open: Vec = Vec::new(); + // Paths first: a free end marks a chain. What is left with both ends linked is a cycle. + open_chains(&pieces, &excluded, &link, &mut visited, &mut open); + closed_loops(&pieces, &excluded, &link, &mut visited, &mut loops); + + let total = loops.len(); + let page: Vec = loops.into_iter().skip(offset).take(limit).collect(); + let returned = page.len(); + let truncated = returned < total.saturating_sub(offset); + let open_total = open.len(); + let open_page: Vec = open.into_iter().take(limit).collect(); + let open_truncated = open_page.len() < open_total; + let junction_total = junctions.len(); + let junction_page: Vec = junctions.into_iter().take(limit).collect(); + let junction_truncated = junction_page.len() < junction_total; + Ok(json!({ + "schema": 1, + "tolerance": tol, + "pairTests": needed, + "pairTestsNeeded": needed, + "pairLimit": pair_limit, + "capped": false, + "count": total, + "returned": returned, + "offset": offset, + "truncated": truncated, + "loops": page, + "openChains": { "count": open_total, "truncated": open_truncated, "chains": open_page }, + "junctions": { "count": junction_total, "truncated": junction_truncated, "junctions": junction_page }, + "skipped": skipped_json(skipped), + })) +} + +/// A report with nothing analyzed: an empty drawing, or one past the pair cap. +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, + "tolerance": tol, + "pairTests": pair_tests, + "pairTestsNeeded": pair_tests_needed, + "pairLimit": pair_limit, + "capped": capped, + "count": 0, + "returned": 0, + "offset": offset, + "truncated": false, + "loops": [], + "openChains": { "count": 0, "truncated": false, "chains": [] }, + "junctions": { "count": 0, "truncated": false, "junctions": [] }, + "skipped": skipped_json(skipped), + }) +} + +fn skipped_json(skipped: Skipped) -> Value { + let m: BTreeMap<&str, Value> = + skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::>().join("; ") }))).collect(); + json!(m) +} + +fn skip(skipped: &mut Skipped, ty: &'static str, why: &'static str) { + let entry = skipped.entry(ty).or_default(); + entry.0 += 1; + entry.1.insert(why); +} + +/// The piece for a LINE or ARC, or `None` when the entity is not chainable. +fn chainable(handle: Handle, kind: &EntityKind, skipped: &mut Skipped) -> Option { + match kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + if !a.is_finite() || !b.is_finite() || a.dist(b) <= EPS { + skip(skipped, "Line", "zero length"); + return None; + } + Some(Piece { handle, start: a, end: b, arc: None }) + } + EntityKind::Arc(a) => { + let (c, r) = (a.center.xy(), a.radius.abs()); + if !c.is_finite() || !a.radius.is_finite() || r <= EPS || !a.start.is_finite() || !a.end.is_finite() { + skip(skipped, "Arc", "degenerate"); + return None; + } + let arc = GeoArc::new(c, r, a.start, a.end); + let (start, end) = (arc.start_point(), arc.end_point()); + if !start.is_finite() || !end.is_finite() { + skip(skipped, "Arc", "degenerate"); + return None; + } + Some(Piece { handle, start, end, arc: Some(arc) }) + } + _ => None, + } +} + +fn find(parent: &mut [usize], mut x: usize) -> usize { + while let Some(&p) = parent.get(x) { + if p == x { + return x; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(x) { + *slot = gp; + } + x = gp; + } + x +} + +/// Walk the cycles left after the open paths from a free end, and report their geometry. +fn closed_loops(pieces: &[Piece], excluded: &[bool], link: &[Option], visited: &mut [bool], out: &mut Vec) { + for i in 0..pieces.len() { + if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) { + continue; + } + // Only a piece linked at both ends can be in a cycle. + if link.get(i * 2).copied().flatten().is_none() || link.get(i * 2 + 1).copied().flatten().is_none() { + continue; + } + let mut members: Vec = Vec::new(); + let mut forward: Vec = Vec::new(); + let (mut cur, mut entry) = (i, 0usize); + let mut closed = false; + loop { + if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() { + break; + } + if let Some(slot) = visited.get_mut(cur) { + *slot = true; + } + members.push(cur); + forward.push(entry == 0); + let exit = 1 - entry; + let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break }; + let (next, which) = (q / 2, q % 2); + if next == i && which == 0 { + closed = true; + break; + } + if visited.get(next).copied().unwrap_or(true) { + break; + } + cur = next; + entry = which; + } + if !closed || members.is_empty() { + continue; + } + let segs = traversed(pieces, &members, &forward); + let (perimeter, area, signed) = facts(&segs); + out.push(json!({ + "members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::>(), + "perimeter": perimeter, + "area": area, + "winding": if signed < 0.0 { "cw" } else { "ccw" }, + })); + } +} + +/// Walk paths from every free end: an open chain, with the gap between its two ends. +fn open_chains(pieces: &[Piece], excluded: &[bool], link: &[Option], visited: &mut [bool], out: &mut Vec) { + for i in 0..pieces.len() { + if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) { + continue; + } + let Some(entry0) = [0usize, 1].into_iter().find(|w| link.get(i * 2 + w).copied().flatten().is_none()) else { continue }; + let mut members: Vec = Vec::new(); + let mut forward: Vec = Vec::new(); + let (mut cur, mut entry) = (i, entry0); + let mut exit = entry0; + loop { + if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() { + break; + } + if let Some(slot) = visited.get_mut(cur) { + *slot = true; + } + members.push(cur); + forward.push(entry == 0); + exit = 1 - entry; + let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break }; + let (next, which) = (q / 2, q % 2); + if visited.get(next).copied().unwrap_or(true) { + break; + } + cur = next; + entry = which; + } + let (Some(first_piece), Some(last_piece)) = (pieces.get(i), members.last().and_then(|&m| pieces.get(m))) else { continue }; + let (first, last) = (first_piece.at(entry0), last_piece.at(exit)); + let gap = first.dist(last); + let at = first * 0.5 + last * 0.5; + let length: f64 = members.iter().filter_map(|&m| pieces.get(m)).map(Piece::length).sum(); + out.push(json!({ + "members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::>(), + "gap": gap, + "at": [at.x, at.y], + "ends": [[first.x, first.y], [last.x, last.y]], + "length": length, + })); + } +} + +fn traversed(pieces: &[Piece], members: &[usize], forward: &[bool]) -> Vec { + members + .iter() + .zip(forward) + .filter_map(|(&m, &fwd)| { + let p = pieces.get(m)?; + Some(Traversed { start: if fwd { p.start } else { p.end }, end: if fwd { p.end } else { p.start }, arc: p.arc.map(|a| (a, fwd)) }) + }) + .collect() +} + +/// Perimeter, unsigned area and signed area (CCW positive, as SHAPES reports winding) of a closed +/// chain of lines and arcs. The closing chord between the last end and the first is the chain's gap +/// and counts in the perimeter, as an open polyline with matching ends does in SHAPES. +fn facts(segs: &[Traversed]) -> (f64, f64, f64) { + let Some(first) = segs.first() else { return (0.0, 0.0, 0.0) }; + let perimeter: f64 = segs.iter().map(seg_len).sum::() + segs.last().map_or(0.0, |last| last.end.dist(first.start)); + let signed = signed_area(segs, first.start); + (perimeter, signed.abs(), signed) +} + +fn seg_len(t: &Traversed) -> f64 { + match t.arc { + Some((a, _)) => a.radius * a.sweep(), + None => t.start.dist(t.end), + } +} + +/// Green's theorem over the closed path, measured about its first point so a small loop far from +/// the origin keeps its area. An arc contributes its exact circular integral, not a tessellation. +fn signed_area(segs: &[Traversed], origin: Vec2) -> f64 { + let mut twice = 0.0; + for t in segs { + match t.arc { + None => twice += (t.start - origin).cross(t.end - origin), + Some((a, forward)) => { + let sweep = a.sweep(); + let (from, to, d) = if forward { (a.start, a.start + sweep, sweep) } else { (a.end, a.end - sweep, -sweep) }; + let c = a.center - origin; + twice += a.radius * a.radius * d + a.radius * (c.x * (to.sin() - from.sin()) - c.y * (to.cos() - from.cos())); + } + } + } + twice / 2.0 +} diff --git a/crates/engine/tests/loops.rs b/crates/engine/tests/loops.rs new file mode 100644 index 00000000..7db98d48 --- /dev/null +++ b/crates/engine/tests/loops.rs @@ -0,0 +1,287 @@ +//! LOOPS: implied closed loops from loose LINE and ARC chains (#1437). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_engine::doc::{Arc, Common, EntityKind, Line, LwPolyline, Space}; +use serde_json::{Value, json}; + +fn handle(v: &Value) -> String { + v["handle"].as_str().or_else(|| v["handles"][0].as_str()).expect("a handle").to_string() +} + +fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> String { + handle(&s.execute("line", &json!({ "points": [[a.0, a.1], [b.0, b.1]] })).unwrap()) +} + +fn arc(s: &mut Session, center: (f64, f64), radius: f64, start: f64, end: f64) -> String { + handle(&s.execute("arc", &json!({ "center": [center.0, center.1], "radius": radius, "start": start, "end": end })).unwrap()) +} + +/// Add an entity straight to the document, for shapes the commands refuse to draw. +fn add(s: &mut Session, kind: EntityKind) -> String { + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +fn rectangle(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> Vec { + vec![line(s, (x, y), (x + w, y)), line(s, (x + w, y), (x + w, y + h)), line(s, (x + w, y + h), (x, y + h)), line(s, (x, y + h), (x, y))] +} + +fn loops(s: &mut Session, p: Value) -> Value { + s.execute("loops", &p).unwrap() +} + +fn num(v: &Value) -> f64 { + v.as_f64().unwrap() +} + +fn near(v: &Value, want: f64) { + assert!((num(v) - want).abs() < 1e-9, "{v} != {want}"); +} + +#[test] +fn four_lines_make_a_closed_loop() { + let mut s = Session::new(); + let hs = rectangle(&mut s, 0.0, 0.0, 10.0, 10.0); + let before = s.doc().unwrap().clone(); + let revision = s.state().unwrap().revision; + let r = loops(&mut s, json!({})); + assert_eq!(r["schema"], 1); + assert_eq!(r["capped"], false); + assert_eq!((r["count"].clone(), r["returned"].clone(), r["offset"].clone()), (json!(1), json!(1), json!(0))); + assert_eq!(r["truncated"], false); + assert!(num(&r["tolerance"]) > 0.0 && num(&r["tolerance"]).is_finite(), "{r}"); + assert!(r["pairTests"].as_u64().unwrap() >= 28, "8 endpoints: {r}"); + let loop_ = &r["loops"][0]; + assert_eq!(loop_["members"], json!(hs)); + assert_eq!(loop_["winding"], "ccw"); + near(&loop_["area"], 100.0); + near(&loop_["perimeter"], 40.0); + assert_eq!(r["openChains"]["count"], 0, "{r}"); + assert_eq!(r["junctions"]["count"], 0, "{r}"); + // Read-only: no entity, no undo step, no revision. + assert_eq!(s.state().unwrap().revision, revision); + assert_eq!(*s.doc().unwrap(), before); +} + +#[test] +fn loop_facts_match_shapes() { + let mut s = Session::new(); + rectangle(&mut s, 0.0, 0.0, 10.0, 10.0); + let vertices: Vec = + [(5.0, 0.0), (15.0, 0.0), (15.0, 10.0), (5.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + s.doc_mut() + .unwrap() + .add( + &Space::Model, + Common::default(), + EntityKind::LwPolyline(LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }), + ) + .unwrap(); + let shapes = s.execute("shapes", &json!({})).unwrap(); + let loops = loops(&mut s, json!({})); + let (a, b) = (&loops["loops"][0], &shapes["shapes"][0]); + for k in ["perimeter", "area", "winding"] { + assert_eq!(a[k], b[k], "{k}: {a} vs {b}"); + } +} + +#[test] +fn a_corner_off_by_a_gap_is_an_open_chain() { + let mut s = Session::new(); + let hs = vec![ + line(&mut s, (0.0, 0.0), (10.0, 0.0)), + line(&mut s, (10.0, 0.0), (10.0, 10.0)), + line(&mut s, (10.0, 10.0), (0.0, 10.0)), + line(&mut s, (0.0, 10.0), (0.0, 0.5)), + ]; + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 0, "{r}"); + assert_eq!(r["openChains"]["count"], 1, "{r}"); + let c = &r["openChains"]["chains"][0]; + assert_eq!(c["members"], json!(hs)); + near(&c["gap"], 0.5); + near(&c["at"][0], 0.0); + near(&c["at"][1], 0.25); + assert_eq!(c["ends"], json!([[0.0, 0.0], [0.0, 0.5]])); + near(&c["length"], 39.5); + // A tolerance that spans the gap closes the loop; one that does not leaves it open. + let r = loops(&mut s, json!({ "tolerance": 0.4 })); + assert_eq!((r["count"].clone(), r["openChains"]["count"].clone()), (json!(0), json!(1)), "{r}"); + let r = loops(&mut s, json!({ "tolerance": 0.6 })); + assert_eq!((r["count"].clone(), r["openChains"]["count"].clone()), (json!(1), json!(0)), "{r}"); + near(&r["loops"][0]["area"], 100.0); + near(&r["loops"][0]["perimeter"], 40.0); +} + +#[test] +fn a_slot_of_lines_and_arcs() { + let mut s = Session::new(); + let hs = vec![ + line(&mut s, (0.0, -1.0), (10.0, -1.0)), + arc(&mut s, (10.0, 0.0), 1.0, -90.0, 90.0), + line(&mut s, (10.0, 1.0), (0.0, 1.0)), + arc(&mut s, (0.0, 0.0), 1.0, 90.0, 270.0), + ]; + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 1, "{r}"); + let loop_ = &r["loops"][0]; + assert_eq!(loop_["members"], json!(hs)); + assert_eq!(loop_["winding"], "ccw"); + let pi = std::f64::consts::PI; + near(&loop_["area"], 20.0 + pi); + near(&loop_["perimeter"], 20.0 + 2.0 * pi); + assert_eq!(r["openChains"]["count"], 0, "{r}"); +} + +#[test] +fn clockwise_chain_reports_cw() { + let mut s = Session::new(); + // The same rectangle drawn the other way round. + let hs = vec![ + line(&mut s, (0.0, 0.0), (0.0, 10.0)), + line(&mut s, (0.0, 10.0), (10.0, 10.0)), + line(&mut s, (10.0, 10.0), (10.0, 0.0)), + line(&mut s, (10.0, 0.0), (0.0, 0.0)), + ]; + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["loops"][0]["members"], json!(hs)); + assert_eq!(r["loops"][0]["winding"], "cw"); + near(&r["loops"][0]["area"], 100.0); +} + +#[test] +fn a_t_junction_is_reported_not_resolved() { + let mut s = Session::new(); + let hs = [line(&mut s, (0.0, 0.0), (10.0, 0.0)), line(&mut s, (0.0, 0.0), (0.0, 10.0)), line(&mut s, (0.0, 0.0), (-10.0, 0.0))]; + // A far-away rectangle still closes: the junction does not poison the whole report. + let rect = rectangle(&mut s, 100.0, 0.0, 10.0, 10.0); + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["loops"][0]["members"], json!(rect)); + assert_eq!(r["junctions"]["count"], 1, "{r}"); + let j = &r["junctions"]["junctions"][0]; + assert_eq!(j["ends"], 3, "{r}"); + near(&j["at"][0], 0.0); + near(&j["at"][1], 0.0); + let mut members: Vec<&str> = j["members"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + members.sort_unstable(); + let mut want: Vec<&str> = hs.iter().map(String::as_str).collect(); + want.sort_unstable(); + assert_eq!(members, want); + // Nothing around the junction is resolved into a chain either. + let open_members: Vec<&Value> = r["openChains"]["chains"].as_array().unwrap().iter().flat_map(|c| c["members"].as_array().unwrap()).collect(); + assert!(open_members.iter().all(|m| !hs.iter().any(|h| h == m.as_str().unwrap())), "{r}"); +} + +#[test] +fn paging_caps_loops_open_chains_and_junctions() { + let mut s = Session::new(); + for i in 0..3 { + rectangle(&mut s, i as f64 * 100.0, 0.0, 10.0, 10.0); + } + let r = loops(&mut s, json!({ "limit": 1 })); + assert_eq!((r["count"].clone(), r["returned"].clone(), r["truncated"].clone()), (json!(3), json!(1), json!(true)), "{r}"); + let r = loops(&mut s, json!({ "limit": 1, "offset": 1 })); + assert_eq!((r["returned"].clone(), r["offset"].clone(), r["truncated"].clone()), (json!(1), json!(1), json!(true)), "{r}"); + let r = loops(&mut s, json!({ "offset": 2 })); + assert_eq!((r["returned"].clone(), r["truncated"].clone()), (json!(1), json!(false)), "{r}"); + + let mut s = Session::new(); + for i in 0..3 { + line(&mut s, (i as f64 * 100.0, 0.0), (i as f64 * 100.0 + 1.0, 0.0)); + } + let r = loops(&mut s, json!({ "limit": 2 })); + assert_eq!( + (r["openChains"]["count"].clone(), r["openChains"]["chains"].as_array().map(Vec::len).unwrap(), r["openChains"]["truncated"].clone()), + (json!(3), 2, json!(true)), + "{r}" + ); +} + +#[test] +fn the_pair_cap_is_reported() { + // The default cap with more endpoints than it can compare. + let mut s = Session::new(); + for i in 0..800 { + let x = i as f64 * 10.0; + add(&mut s, EntityKind::Line(Line { a: Vec3::new(x, 0.0, 0.0), b: Vec3::new(x + 1.0, 0.0, 0.0) })); + } + let r = loops(&mut s, json!({})); + assert_eq!(r["capped"], true, "{r}"); + assert_eq!(r["count"], 0, "{r}"); + assert_eq!(r["pairTests"], 0, "{r}"); + assert_eq!(r["pairLimit"].as_u64().unwrap(), 1_000_000, "{r}"); + assert!(r["pairTestsNeeded"].as_u64().unwrap() > 1_000_000, "{r}"); + + // An explicit, smaller cap with a rectangle's 28 pairs. + let mut s = Session::new(); + rectangle(&mut s, 0.0, 0.0, 10.0, 10.0); + let r = loops(&mut s, json!({ "maxPairs": 10 })); + assert_eq!(r["capped"], true, "{r}"); + assert_eq!((r["pairTests"].clone(), r["pairLimit"].clone(), r["pairTestsNeeded"].clone()), (json!(0), json!(10), json!(28)), "{r}"); + assert_eq!(r["loops"].as_array().map(Vec::len), Some(0), "{r}"); +} + +#[test] +fn degenerate_entities_and_bad_options_never_panic() { + let mut s = Session::new(); + add(&mut s, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::ZERO })); + add(&mut s, EntityKind::Line(Line { a: Vec3::new(f64::NAN, 0.0, 0.0), b: Vec3::new(1.0, 0.0, 0.0) })); + add(&mut s, EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 0.0, start: 0.0, end: 1.0 })); + add(&mut s, EntityKind::Arc(Arc { center: Vec3::new(0.0, f64::NAN, 0.0), radius: 1.0, start: 0.0, end: 1.0 })); + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 0, "{r}"); + assert_eq!(r["skipped"]["Line"]["count"], 2, "{r}"); + assert_eq!(r["skipped"]["Arc"]["count"], 2, "{r}"); + // A full-circle ARC closes on its own. + let h = add(&mut s, EntityKind::Arc(Arc { center: Vec3::new(50.0, 0.0, 0.0), radius: 2.0, start: 0.0, end: 0.0 })); + let r = loops(&mut s, json!({})); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(r["loops"][0]["members"], json!([h])); + near(&r["loops"][0]["area"], 4.0 * std::f64::consts::PI); + near(&r["loops"][0]["perimeter"], 4.0 * std::f64::consts::PI); +} + +#[test] +fn coincident_endpoints_never_panic() { + let mut s = Session::new(); + line(&mut s, (0.0, 0.0), (10.0, 0.0)); + line(&mut s, (0.0, 0.0), (10.0, 0.0)); + let r = loops(&mut s, json!({})); + // Two coincident lines join into a zero-area loop; what matters is that it is reported, not + // that it panics or modifies the drawing. + assert_eq!(r["junctions"]["count"], 0, "{r}"); + assert_eq!(r["count"], 1, "{r}"); + near(&r["loops"][0]["area"], 0.0); +} + +#[test] +fn a_line_whose_own_ends_meet_within_a_large_tolerance_is_still_open() { + let mut s = Session::new(); + let h = line(&mut s, (0.0, 0.0), (0.1, 0.0)); + let r = loops(&mut s, json!({ "tolerance": 1.0 })); + assert_eq!(r["count"], 0, "{r}"); + assert_eq!(r["openChains"]["count"], 1, "{r}"); + assert_eq!(r["openChains"]["chains"][0]["members"], json!([h])); + near(&r["openChains"]["chains"][0]["gap"], 0.1); +} + +#[test] +fn bad_options_are_errors() { + let mut s = Session::new(); + for p in [ + json!({ "tolerance": -1 }), + json!({ "tolerance": 0 }), + json!({ "tolerance": "x" }), + json!({ "maxPairs": "x" }), + json!({ "maxPairs": -1 }), + json!({ "limit": "x" }), + json!({ "offset": "x" }), + ] { + assert!(s.execute("loops", &p).is_err(), "{p}"); + } + // Empty drawing: a well-formed empty report. + let r = loops(&mut s, json!({})); + assert_eq!((r["count"].clone(), r["returned"].clone(), r["capped"].clone()), (json!(0), json!(0), json!(false)), "{r}"); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8987b071..73c0f3c6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1281,6 +1281,7 @@ Run Script... Виконати сценарій... Query Texts Запит текстів Analyze Shapes Аналіз фігур + Implied Loops Неявні контури Hatch Pick Вибір для штрихування # Hatch and Gradient dialog Hatch and Gradient Штрихування та градієнт diff --git a/docs/mcp.md b/docs/mcp.md index 51e6107e..3f2422b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -43,6 +43,7 @@ Example client configuration (Claude Code / Claude Desktop): | `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. | | `query_entities {type?, layer?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | | `execute {"command": "shapes", "params": {type?, layer?, window?, crossing?, limit?, offset?, tolerance?}}` | Facts about the closed shapes — closed polylines, circles, full ellipses, closed splines: perimeter, area, centroid, winding, the first self-intersection and coincident vertices, exact with arcs and bulges. Other matches are counted under `skipped` with the reason. | +| `execute {"command": "loops", "params": {tolerance?, maxPairs?, limit?, offset?}}` | Implied closed loops from loose LINE and ARC chains: each closed loop with its member handles, perimeter, area and winding, exact with arcs; each open chain with the endpoint gap, its location (`at`, `ends`) and length; and branch junctions (three or more ends meeting), reported but not resolved. `tolerance` defaults to 1e-9 × the extents diagonal; the quadratic endpoint pairing stops at `maxPairs` (default 1,000,000) and reports `capped` with `pairTestsNeeded` instead of guessing from a partial pairing. | | `query_entities {type?, layer?, selected?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `selected: true` restricts results to the current selection in the active space; an empty selection returns no entities. Omitted/false queries all entities in that space. Filters combine before paging. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | | `render {width?, height?, fit?, window?}` | A PNG image of the drawing, or of just the area `window` `[[x0,y0],[x1,y1]]` (one sheet or room of a large drawing; 1600 px wide with the window's shape unless sized). | | `find_text {contains?, layer?, window?, limit?, offset?}` | The readable texts — TEXT, MTEXT without formatting codes, block attributes, multileaders — with text, insertion point, height, rotation, style, layer and handle; filtered by content (any case), layer and a window around the insertion points. Read-only (the `texts` command). |