From fe4d171e22beaa3a787033b11a18fccd6c463742 Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Mon, 12 Oct 2026 13:45:48 -0400 Subject: [PATCH] feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341) * feat(sketch): constructive solving ahead of the numeric solver Part 1 of splitting #251: the sketch crate only. A graph-constructive front end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the rest, and the rank analysis stays authoritative for degrees of freedom. Solves can follow a reference (the previous solution), so edits and drags keep their branch instead of flipping. New API: solve_with (hold, reference, analysis, budget) and solve_after_value_edit; solve and solve_holding keep their signatures as wrappers, so no caller changes here. When the seed moves an arc's centre, the arc's unconstructed ends start carried along (the arc translates) instead of left behind for LM to turn; found by the reference test 20-slot-constrained. Refs #203 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(doc): keep a sketch's solved state across value edits Part 2 of splitting #251 (builds on part 1, the sketch crate): - Sketch edits solve for the document's values continuing from the stored solved state (Document::solve_sketch), so a dimension edit keeps the sketch's branch instead of re-solving from scratch. - A value edit (parameters, a configuration, the units) that would make a sketch that solved before fail or turn over is refused with the conflicting constraints named; otherwise the solved sketches are written back into the document (Model::adopt_solved_sketches), which changes what saved designs store. - Later features are keyed by a sketch's solved geometry (to a 1e-6 mm quantum), so setting a value back reuses their earlier results. - Solved sketches in a model state are shared (Arc) between states. Refs #203 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(sketch): live drag that keeps its solution, ended by sketch.drag_end Part 3 of splitting #251 (builds on part 2): - sketch.drag follows a long move in short held steps, so the geometry keeps its branch, and reports how far it got (reached, at). - live: true runs one frame of an interactive drag: the sketch is solved once and shown, later features keep their last result (marked stale) until sketch.drag_end or any command that reads the model evaluates it (CommandSpec::live, reads_model, doc_only). - sketch.drag_end adds the constraint the drag let go on and evaluates the model once. - Undo and redo evaluate a new document once (execute does) instead of twice. - The viewport drives drags through live frames and drag_end; dimension text drags start only off sketch geometry. Refs #203 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/document.rs | 22 + crates/doc/src/eval.rs | 288 +++- crates/engine/src/cmd/browser.rs | 4 +- crates/engine/src/cmd/config.rs | 3 +- crates/engine/src/cmd/delete.rs | 3 +- crates/engine/src/cmd/edit.rs | 42 +- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/mod.rs | 41 +- crates/engine/src/cmd/motion.rs | 2 +- crates/engine/src/cmd/parameters.rs | 15 +- crates/engine/src/cmd/parameters_tests.rs | 309 +++- crates/engine/src/cmd/sheet.rs | 2 +- crates/engine/src/cmd/sketch.rs | 324 ++-- crates/engine/src/cmd/sketch_constraints.rs | 10 +- crates/engine/src/cmd/sketch_drag.rs | 545 +++++++ .../engine/src/cmd/sketch_drag_live_tests.rs | 290 ++++ crates/engine/src/cmd/sketch_modify.rs | 29 +- crates/engine/src/cmd/sketch_ux_tests.rs | 659 ++++++++ crates/engine/src/lib.rs | 98 +- crates/engine/src/tests.rs | 6 +- crates/sketch/examples/solve_bench.rs | 108 ++ crates/sketch/src/construct.rs | 352 ++++ crates/sketch/src/construct_rounds_tests.rs | 813 ++++++++++ crates/sketch/src/construct_tests.rs | 1090 +++++++++++++ crates/sketch/src/continuation_tests.rs | 343 ++++ crates/sketch/src/decompose.rs | 1053 ++++++++++++ crates/sketch/src/decompose_tests.rs | 1085 +++++++++++++ crates/sketch/src/dims.rs | 21 +- crates/sketch/src/lib.rs | 23 +- crates/sketch/src/link.rs | 10 +- crates/sketch/src/loci.rs | 1363 ++++++++++++++++ crates/sketch/src/model.rs | 122 +- crates/sketch/src/periodic.rs | 9 +- crates/sketch/src/profiles.rs | 18 +- crates/sketch/src/solver.rs | 1422 ++++++++++++++--- crates/sketch/src/tests.rs | 36 +- crates/sketch/src/union_find.rs | 43 + crates/sketch/src/wire.rs | 12 +- crates/ui-egui/src/agent_cursor.rs | 3 +- crates/ui-egui/src/dialogs.rs | 58 +- crates/ui-egui/src/dim_view.rs | 5 +- crates/ui-egui/src/drag_snap.rs | 14 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/sketch_drag_ui_tests.rs | 107 ++ crates/ui-egui/src/viewport.rs | 52 +- docs/perf.md | 48 + 47 files changed, 10385 insertions(+), 523 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_drag.rs create mode 100644 crates/engine/src/cmd/sketch_drag_live_tests.rs create mode 100644 crates/sketch/examples/solve_bench.rs create mode 100644 crates/sketch/src/construct.rs create mode 100644 crates/sketch/src/construct_rounds_tests.rs create mode 100644 crates/sketch/src/construct_tests.rs create mode 100644 crates/sketch/src/continuation_tests.rs create mode 100644 crates/sketch/src/decompose.rs create mode 100644 crates/sketch/src/decompose_tests.rs create mode 100644 crates/sketch/src/loci.rs create mode 100644 crates/sketch/src/union_find.rs create mode 100644 crates/ui-egui/src/sketch_drag_ui_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 25c3b297..11bdb23e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | # | Milestone | Status | Hours left | |---|---|---|---| | M0 | Vertical slice | done | — | -| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | +| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 54f90c11..8423a25d 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2181,6 +2181,28 @@ impl Document { } Ok(()) } + + /// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values + /// its geometry is solved for (the stored sketch's): what the solve cannot reach in one + /// jump it reaches by stepping the changed values from there, and an edit that would turn + /// the sketch over is refused. Offsets whose sources moved are refreshed and solved again: + /// from the stored state again after a refused or failed first solve (the geometry is still + /// the stored one), from the solved one otherwise. + pub fn solve_sketch( + &self, + vals: &std::collections::BTreeMap, + sk: &mut Sketch, + stored: &solvecraft_sketch::DrivingValues, + ) -> Result { + self.apply_dimension_values(vals, sk)?; + let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored); + if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { + // Source dimensions may have moved curves during the solve. + sk.refresh_offsets()?; + report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) }; + } + Ok(report) + } } /// Density of the default material (g/cm³), as Fusion's default. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5ba2ec55..63ff6d31 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, OnceLock}; use serde::Serialize; use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3}; use solvecraft_kernel::{self as kernel, Body, BoolOp}; -use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve}; +use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles}; use crate::expr::{self, Kind, Value}; use crate::lineage::Traced; @@ -262,7 +262,8 @@ pub struct ModelState { /// Cosmetic threads (from Thread features and threaded holes). pub threads: Vec, pub bodies: Vec, - pub sketches: Vec, + /// Shared between states: a feature that does not change a sketch keeps it as it was. + pub sketches: Vec>, pub body_counter: usize, /// Sheet metal bodies (by body name) and their flat patterns. pub sheets: Vec, @@ -295,7 +296,8 @@ impl ModelState { } /// After a feature evaluated to this state from `before` with cache key `key`: bodies it - /// made or changed, and sketches it made, take that key; the rest keep theirs. + /// made or changed take that key, sketches it made a digest of their solved geometry (see + /// [`quantize`]); the rest keep theirs. fn stamp(&mut self, before: &Arc, f: &Feature, key: u64, mats: Vec) { let feature = f.id; let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1; @@ -342,8 +344,16 @@ impl ModelState { } } } - if self.sketches.iter().any(|s| s.feature == feature) { - self.sketch_keys.insert(feature, key); + // Later features read a sketch's solved geometry: they are keyed by it (to the + // quantum), so a value set back that solves to the same geometry reuses their results. + if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) { + let mut h = std::collections::hash_map::DefaultHasher::new(); + serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h); + let mut sketch = s.sketch.clone(); + quantize(&mut sketch); + serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h); + s.report.ok().hash(&mut h); + self.sketch_keys.insert(feature, h.finish()); } } } @@ -394,7 +404,7 @@ impl ModelState { self.bodies.iter().find(|b| b.name == name) } pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> { - self.sketches.iter().find(|s| s.feature == feature) + self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s) } } @@ -408,6 +418,11 @@ pub struct FeatureResult { pub boolean_diagnostics: kernel::BooleanDiagnostics, pub ms: f64, pub skipped: bool, + /// Not evaluated on the document's current inputs: a feature at or after a sketch shown live + /// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which + /// never reuses it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stale: bool, #[serde(skip)] fingerprint: u64, #[serde(skip)] @@ -448,7 +463,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba prev.hash(&mut h); rolled_back.hash(&mut h); vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h); - serde_json::to_string(f).unwrap_or_default().hash(&mut h); + feature_json(f).hash(&mut h); for n in param_names(f) { n.hash(&mut h); match vals.get(&n) { @@ -463,6 +478,123 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } +/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design +/// can show, far above the solver's rounding, so a sketch solved again to the same geometry +/// reads as the same input. +const QUANTUM: f64 = 1e-6; + +/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the +/// measured values of driven dimensions. +fn quantize(sk: &mut solvecraft_sketch::Sketch) { + let q = |v: f64| (v / QUANTUM).round() * QUANTUM; + for p in &mut sk.points { + p.pos = Vec2::new(q(p.pos.x), q(p.pos.y)); + } + for c in &mut sk.curves { + if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind { + *r = q(*r); + } + } + for c in sk.constraints.iter_mut().filter(|c| c.driven) { + if let Some(v) = c.kind.value() { + c.kind.set_value(q(v)); + } + } +} + +/// A feature as fingerprinted: its definition, a sketch's stored solve quantized. +fn feature_json(f: &Feature) -> String { + if let FeatureKind::Sketch { .. } = &f.kind { + let mut g = f.clone(); + if let FeatureKind::Sketch { sketch, .. } = &mut g.kind { + quantize(sketch); + } + return serde_json::to_string(&g).unwrap_or_default(); + } + serde_json::to_string(f).unwrap_or_default() +} + +/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse +/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the +/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's +/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not +/// match `stored` entity for entity is not adopted. +fn adopt_solution(stored: &mut solvecraft_sketch::Sketch, solved: &solvecraft_sketch::Sketch) { + if stored.points.len() != solved.points.len() + || stored.curves.len() != solved.curves.len() + || stored.constraints.len() != solved.constraints.len() + || stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id) + { + return; + } + for (p, q) in stored.points.iter_mut().zip(&solved.points) { + p.pos = q.pos; + } + for (c, d) in stored.curves.iter_mut().zip(&solved.curves) { + match (&mut c.kind, &d.kind) { + (CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => { + *r = *s + } + (CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => { + (*a, *b) = (*sa, *sb); + c.reversed = d.reversed; + } + _ => {} + } + } + for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) { + if let Some(v) = d.kind.value() { + c.kind.set_value(v); + } + } +} + +/// The values features are evaluated with: the parameters, and the design's length unit. +fn evaluation_values(doc: &Document) -> (BTreeMap, BTreeMap) { + let (mut vals, perr) = doc.param_values(); + if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim()) + && scale != 1.0 + { + vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 }); + } + (vals, perr) +} + +/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and +/// the sheet metal or plastic rules it takes defaults from. +fn chain_fingerprint(doc: &Document, vals: &BTreeMap, f: &Feature, prev: u64, rolled_back: bool) -> u64 { + let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f); + // Sheet metal features also depend on the rules (and the parameters they use). + if f.kind.is_sheet() { + let mut h = std::collections::hash_map::DefaultHasher::new(); + fp.hash(&mut h); + serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); + for r in &doc.sheet.rules { + for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] { + for n in expr::references(e) { + vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); + } + } + } + fp = h.finish(); + } + // Plastic features take defaults from the plastic rules. + if f.kind.is_plastic() && !doc.plastic.is_empty() { + let mut h = std::collections::hash_map::DefaultHasher::new(); + fp.hash(&mut h); + serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h); + for r in &doc.plastic.rules { + for (_, e, _) in r.fields() { + for n in expr::references(e) { + vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); + } + } + } + fp = h.finish(); + } + fp +} + /// Document-level inputs any feature may read: construction planes (resolved by name), sheet /// metal and plastic rules (and the parameters they use), components and body placement. fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { @@ -579,14 +711,79 @@ impl Model { self.results.iter().find(|r| r.id == id) } + /// Show a sketch's new solution before the model is evaluated again (the frames of an + /// interactive drag): `sketch`, already solved with `report`, on the plane it was last + /// evaluated on. The sketch's own result and the final state show it, and the sketch and + /// every feature after it are marked stale, keeping their last geometry. Nothing else is + /// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`, + /// changing nothing, when the sketch has no evaluated result or a feature before it is + /// already stale (only a full evaluation brings that up to date). + pub fn show_live_sketch(&mut self, feature: u64, sketch: solvecraft_sketch::Sketch, report: SolveReport) -> bool { + let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false }; + if self.results.iter().take(i).any(|r| r.stale) { + return false; + } + let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else { + return false; + }; + let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report }); + let shown = self.results.iter().rposition(|r| !r.skipped); + for (k, r) in self.results.iter_mut().enumerate().skip(i) { + r.stale = true; + // The sketch's own state and the final one show it: only that entry changes (the + // states are copied on the first frame; later frames own them and swap in place). + if (k == i || Some(k) == shown) + && let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature) + { + *s = solved.clone(); + } + } + true + } + + /// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`. + pub fn is_stale(&self) -> bool { + self.results.iter().any(|r| r.stale) + } + + /// Store in `doc` the sketches whose last evaluation solved them for new driving values + /// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value + /// edit continues from (edits depend on their history, as a user editing dimensions one + /// after another expects) and later evaluations repeat no continuation. Their results + /// stand for the updated document: the solved sketch is what evaluating it gives. + /// Returns how many sketches were stored. + pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize { + let solved: Vec = self.edited_sketches(doc).cloned().collect(); + let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 }; + for s in &solved { + if let Ok(stored) = doc.sketch_mut(s.feature) { + adopt_solution(stored, &s.sketch); + } + } + // The fingerprints from the first stored sketch on. + let vals = evaluation_values(doc).0; + let marker = doc.marker.unwrap_or(usize::MAX); + let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint); + for (i, f) in doc.features.iter().enumerate().skip(first) { + prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker); + if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) { + r.fingerprint = prev_fp; + } + } + solved.len() + } + + /// The sketches the last evaluation solved for driving values other than the ones `doc` + /// stores them with (a value edit since they were stored). + pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator + 'a { + self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| { + s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values()) + }) + } + /// Re-evaluate the document, reusing results up to the first changed feature. pub fn evaluate(&mut self, doc: &Document) { - let (mut vals, perr) = doc.param_values(); - if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim()) - && scale != 1.0 - { - vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 }); - } + let (vals, perr) = evaluation_values(doc); self.param_errors = perr; let mut prev_fp = 0u64; let mut state = self.empty.clone(); @@ -598,40 +795,13 @@ impl Model { let ctx = context_digest(doc, &vals); for (i, f) in doc.features.iter().enumerate() { let rolled_back = i >= marker; - let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f); - // Sheet metal features also depend on the rules (and the parameters they use). - if f.kind.is_sheet() { - let mut h = std::collections::hash_map::DefaultHasher::new(); - fp.hash(&mut h); - serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); - for r in &doc.sheet.rules { - for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] { - for n in expr::references(e) { - vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); - } - } - } - fp = h.finish(); - } - // Plastic features take defaults from the plastic rules. - if f.kind.is_plastic() && !doc.plastic.is_empty() { - let mut h = std::collections::hash_map::DefaultHasher::new(); - fp.hash(&mut h); - serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h); - for r in &doc.plastic.rules { - for (_, e, _) in r.fields() { - for n in expr::references(e) { - vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); - } - } - } - fp = h.finish(); - } + let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back); prev_fp = fp; if reuse && let Some(old) = self.results.get(i) && old.id == f.id && old.fingerprint == fp + && !old.stale { if !old.skipped { state = old.state.clone(); @@ -649,6 +819,7 @@ impl Model { boolean_diagnostics: Default::default(), ms: 0.0, skipped: true, + stale: false, fingerprint: fp, state: state.clone(), }); @@ -671,6 +842,7 @@ impl Model { boolean_diagnostics: c.boolean_diagnostics, ms: c.ms, skipped: false, + stale: false, fingerprint: fp, state: state.clone(), }); @@ -775,6 +947,7 @@ impl Model { boolean_diagnostics, ms, skipped: false, + stale: false, fingerprint: fp, state: state.clone(), }); @@ -2920,15 +3093,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if let Some(w) = face_warning { warns.insert(0, w); } - doc.apply_dimension_values(vals, &mut sk)?; - let mut report = solve(&mut sk); - if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { - // Source dimensions may have moved curves during the solve. - sk.refresh_offsets()?; - report = solve(&mut sk); - } - if !report.ok() { - warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", "))); + // The stored sketch is solved for its stored values. + let stored = sk.driving_values(); + let report = doc.solve_sketch(vals, &mut sk, &stored)?; + if !report.keeps_orientation() { + warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})"))); } if !warns.is_empty() { *warning = Some(warns.join("; ")); @@ -2936,7 +3105,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: sk.validate_periodic_splines()?; let profiles = find_profiles(&sk); st.sketches.retain(|s| s.feature != f.id); - st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); + st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles })); Ok(()) } FeatureKind::Extrude { operation, targets, .. } @@ -3580,6 +3749,12 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram } } for (s, m) in out.sketches.iter_mut().zip(&sketch_m) { + // Only a sketch the frame moves is copied. + let maps_wire = |w: &solvecraft_sketch::Wire| !crate::is_identity(if w.space == solvecraft_sketch::WireSpace::Component { m } else { &inv }); + if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) { + continue; + } + let s = Arc::make_mut(s); if !crate::is_identity(m) && let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y)) { @@ -3646,7 +3821,12 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { } if primary { for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) { - let mut s2 = s.clone(); + if ident { + // In place: shared as it is. + out.sketches.push(s.clone()); + continue; + } + let mut s2 = SolvedSketch::clone(s); if !ident { let p = &s.plane; if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) { @@ -3658,7 +3838,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { map_wire(wire, &m); } } - out.sketches.push(s2); + out.sketches.push(Arc::new(s2)); } for t in st .threads diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 710f76e7..d03501ca 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"), CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"), CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"), - CommandSpec::new("view.list", "List Named Views", view_list).noundo(), + CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(), CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params( "visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \ @@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result { // What the geometry reads: every feature's length inputs. let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten(); let before = s.doc.length_inputs(); + let solved = s.solved_sketches(); s.doc_mut().units = u.to_string(); // Unit-less user parameters holding a bare number read it in the design's units where they // stand for lengths. If any input moved, those numbers keep the old unit, written out. @@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result { if let Some(row) = matched_configuration { s.doc_mut().apply_configuration(&row)?; } + s.settle_value_edit(&solved)?; Ok(json!({ "units": u, "kept": kept })) } diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index d5335c98..171cb20f 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; ")))); } let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?; + let solved = s.solved_sketches(); s.doc = trial.doc; - s.refresh(); + s.settle_value_edit(&solved)?; let errors: Vec = s.model.results.iter().filter_map(|x| x.error.clone()).collect(); let mut out = show(s, p)?; out["errors"] = json!(errors); diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index ed5d8242..5699c20d 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De /// Run another command's body inside this one (so the whole delete is one undo step). fn sub(s: &mut Session, id: &str, p: Value) -> Result { let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - (spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; - let r = (spec.run)(s, &p)?; + let r = spec.run_nested(s, &p)?; s.refresh(); Ok(r) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 872f8643..b4072325 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list}; use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(), - CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(), + CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(), + CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(), CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint) .noundo() .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"), @@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[ fn undo(s: &mut Session, _p: &Value) -> Result { let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) }; - let label = prev.label.clone(); - s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); - s.doc = prev.doc; - s.active_sketch = prev.active_sketch; - s.pending_moves = prev.pending_moves; - s.selection.clear(); - s.refresh(); + let now = go_to(s, prev); + let label = now.label.clone(); + s.redo.push(now); Ok(json!({"undone": label})) } fn redo(s: &mut Session, _p: &Value) -> Result { let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) }; - let label = next.label.clone(); - s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); - s.doc = next.doc; - s.active_sketch = next.active_sketch; - s.pending_moves = next.pending_moves; - s.selection.clear(); - s.refresh(); + let now = go_to(s, next); + let label = now.label.clone(); + s.undo.push(now); Ok(json!({"redone": label})) } +/// Put the session at `snap`; returns where it was, under the same label. +fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot { + let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }; + let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc); + s.doc = snap.doc; + s.active_sketch = snap.active_sketch; + s.pending_moves = snap.pending_moves; + s.selection.clear(); + // A new document is evaluated by `execute`. + if same { + s.refresh(); + } + was +} + fn checkpoint(s: &mut Session, _p: &Value) -> Result { Ok(json!({"checkpoint": s.checkpoint()})) } @@ -477,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { } trial.doc_mut().marker = Some(idx + 1); trial.refresh(); - (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; - let r = (spec.run)(&mut trial, ¶ms)?; + let r = spec.run_nested(&mut trial, ¶ms)?; let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index cedea1e2..d50339b5 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("appearance.list", "List Appearances", appearance_list) .noundo() .params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"), - CommandSpec::new("material.list", "List Materials", material_list).noundo(), + CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(), CommandSpec::new("engine.commands", "List Commands", commands).noundo(), ]; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e51ed6a9..c4e9e36e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -57,6 +57,10 @@ use serde_json::Value; use crate::{Result, Session}; pub type Run = fn(&mut Session, &Value) -> Result; +/// A command's form for one frame of an interactive gesture: its result and the sketch it +/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`: +/// evaluate as usual). +pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option)>; pub type Enabled = fn(&Session) -> std::result::Result<(), String>; pub struct CommandSpec { @@ -74,11 +78,40 @@ pub struct CommandSpec { pub enabled: Enabled, pub run: Run, pub undoable: bool, + /// What runs instead of `run` when the command is given `live: true` (a frame of an + /// interactive gesture, see [`Session::execute`]). + pub live: Option, + /// Reads the evaluated model (the default). A command that reads only the document and the + /// session's history runs on a model left stale by a live gesture without evaluating it first. + pub reads_model: bool, } impl CommandSpec { pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self { - CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true } + CommandSpec { + id, + label, + tab: "", + panel: "", + icon: "", + shortcut: None, + params: "", + enabled: always, + run, + undoable: true, + live: None, + reads_model: true, + } + } + /// Takes `live: true`, run by `run`. + pub const fn live(mut self, run: LiveRun) -> Self { + self.live = Some(run); + self + } + /// Reads only the document (and history), never the evaluated model. + pub const fn doc_only(mut self) -> Self { + self.reads_model = false; + self } pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self { self.tab = tab; @@ -105,6 +138,12 @@ impl CommandSpec { self.undoable = false; self } + /// Run inside another command: when it is enabled, its `run` (the caller's command owns the + /// undo step and the evaluation). + pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result { + (self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?; + (self.run)(s, p) + } pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 33b6fed5..367e942e 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("joint") .params("occurrences: [ids or names] (two or more); name?"), CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), - CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"), + CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"), CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"), CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params( "name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \ diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 73a48a81..69381cb8 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[ favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \ restored: features whose earlier result for the same inputs was reused (e.g. a value set back)", ), - CommandSpec::new("parameters.list", "List Parameters", list).noundo().params( + CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params( "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ source, feature/input, comment, favorite, uses, used_by, error) and cycles", ), @@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"), CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate) .noundo() + .doc_only() .params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"), CommandSpec::new("parameters.complete", "Complete Expression", complete) .noundo() + .doc_only() .params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"), ]; @@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool { fn change_param(s: &mut Session, p: &Value) -> Result { let cmd = "parameters.change"; + let solved = s.solved_sketches(); if let Some(n) = str_(p, "delete") { s.doc_mut().remove_param(n.trim())?; + s.settle_value_edit(&solved)?; return Ok(json!({"deleted": n.trim()})); } let mut name = param_name(cmd, p)?.to_string(); @@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result { if let Some(f) = bool_(p, "favorite") { s.doc_mut().set_favorite(&name, f)?; } - s.refresh(); + s.settle_value_edit(&solved)?; let (vals, _) = s.doc.param_values(); out["name"] = json!(name); out["expression"] = json!(e); @@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let cmd = "parameters.add"; let name = param_name(cmd, p)?.to_string(); let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?; + let solved = s.solved_sketches(); s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?; + s.settle_value_edit(&solved)?; if let Some(f) = bool_(p, "favorite") { s.doc_mut().set_favorite(&name, f)?; } @@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let name = param_name("parameters.delete", p)?.to_string(); + let solved = s.solved_sketches(); s.doc_mut().remove_param(&name)?; + s.settle_value_edit(&solved)?; Ok(json!({"deleted": name})) } @@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad(cmd, "give `text` or `path`")), }; + let solved = s.solved_sketches(); let names = s.doc_mut().import_params(&text)?; - s.refresh(); + s.settle_value_edit(&solved)?; Ok(json!({"imported": names, "errors": failing(s)})) } diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index db8ae158..9eaa66da 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -67,9 +67,11 @@ fn width_follows_an_expression() { assert!(e.contains("expected a length, got an angle"), "{e}"); } -/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the -/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken -/// for "nothing changed". timeline.edit reports the same. +/// Setting a value back reuses the earlier results and says so: the features it restored are +/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A +/// sketch dimension set back solves the sketch again from its current state (value edits +/// continue from the last solved state); the features after it see the same geometry and are +/// restored. #[test] fn setting_a_parameter_back_reports_restored_features() { let mut s = Session::default(); @@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() { let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"})); assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}"); let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"})); - assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}"); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}"); assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}})); assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}"); @@ -243,3 +245,302 @@ fn csv_and_json_round_trip() { assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm")); let _ = std::fs::remove_dir_all(&dir); } + +/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin, +/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration. +/// Returns the session, the sketch feature and the nine dimensions' parameters. +fn linkage_session() -> (Session, u64, Vec) { + let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]]; + let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)]; + let ids = ["a", "b", "c", "d", "e", "f"]; + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"})); + for (id, at) in ids.iter().zip(layout) { + run(&mut s, "sketch.point", json!({"point": at, "id": id})); + } + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"})); + let params = pairs + .iter() + .map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string()) + .collect(); + let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default(); + (s, feature, params) +} + +/// Edited values the iteration cannot reach in one jump from the layout. +const REACHED_BY_CONTINUATION: [f64; 9] = [ + 49.75798473660107, + 44.249179252138724, + 28.66332553713371, + 49.725624597008775, + 45.81086166277159, + 35.887660702567544, + 45.64700450204954, + 50.756441510188495, + 63.72063313963373, +]; + +/// Turn of both triangles in the evaluated sketch. +fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] { + let state = s.model.state(); + let sk = &state.sketch(feature).unwrap().sketch; + let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap(); + [(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0] +} + +/// Set the parameters in the document only (as an edit that does not store the sketch). +fn set_params(s: &mut Session, params: &[String], values: &[f64]) { + let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect(); + s.doc_mut().change_params_atomic(&changes).unwrap(); +} + +#[test] +fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() { + // The edit steps to the new values from the stored sketch, keeping both triangles' turn, + // and stores what it reached: evaluating the document afresh needs no continuation. + let (mut s, feature, params) = linkage_session(); + run(&mut s, "sketch.finish", json!({})); + let before = linkage_turns(&s, feature); + // All nine at once: one at a time, some intermediate values have no solution. + let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect(); + run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()})); + let solved = s.model.state().sketch(feature).unwrap().report.clone(); + assert!(solved.ok(), "{solved:?}"); + assert_eq!(linkage_turns(&s, feature), before); + let mut fresh = solvecraft_doc::Model::default(); + fresh.evaluate(&s.doc); + let again = fresh.state().sketch(feature).unwrap().report.clone(); + assert!(again.ok() && again.continuations == 0, "{again:?}"); + // The kept results are what evaluating the stored document gives. + let (kept, afresh) = (s.model.state(), fresh.state()); + let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points); + assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9)); + // In one go (one evaluation from the layout) it needs continuation. + let (mut t, feature, params) = linkage_session(); + run(&mut t, "sketch.finish", json!({})); + set_params(&mut t, ¶ms, &REACHED_BY_CONTINUATION); + t.refresh(); + let once = t.model.state().sketch(feature).unwrap().report.clone(); + assert!(once.ok() && once.continuations > 0, "{once:?}"); +} + +#[test] +fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() { + let (mut s, feature, params) = linkage_session(); + run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]})); + run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]})); + let before = linkage_turns(&s, feature); + set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION); + run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]})); + let solved = s.model.state().sketch(feature).unwrap().report.clone(); + assert!(solved.ok(), "{solved:?}"); + assert_eq!(linkage_turns(&s, feature), before); +} + +#[test] +fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() { + // Parameters no geometry satisfies, then an edit that does not insist on a solution: the + // stored dimensions keep the values its geometry is solved for. + let (mut s, feature, params) = linkage_session(); + let mut values = REACHED_BY_CONTINUATION; + values[7] = 500.0; + set_params(&mut s, ¶ms, &values); + run(&mut s, "sketch.point", json!({"point": [200, 200]})); + let stored = s.doc.sketch(feature).unwrap(); + for c in stored.constraints.iter().filter(|c| c.param.is_some()) { + let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap(); + assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value()); + } +} + +/// Values the linkage reaches only with its top triangle mirrored. +const FLIPPING: [f64; 9] = [ + 26.483003374917082, + 90.0253808040292, + 84.67215428223084, + 54.580784880976275, + 68.12871394439186, + 30.98508537669166, + 116.94895853350752, + 103.75535113133466, + 32.83103173640168, +]; + +/// All nine parameters set by one command. +fn import_values(params: &[String], values: &[f64]) -> Value { + let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect(); + json!({"text": serde_json::to_string(&batch).unwrap()}) +} + +#[test] +fn an_edit_that_would_flip_the_sketch_is_refused_and_named() { + let (mut s, feature, params) = linkage_session(); + run(&mut s, "sketch.finish", json!({})); + let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::>()); + let why = err(&mut s, "parameters.import", import_values(¶ms, &FLIPPING)); + assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}"); + assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::>(), before); + assert_eq!(stored_points(&s, feature), points); + assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation()); +} + +#[test] +fn a_design_stored_with_other_values_still_rebuilds() { + // A design whose stored sketch is not solved for its parameters (saved before value edits + // stored their solve): evaluating it never refuses. The flipped solve is kept, with a + // warning, the flipped constraints listed as conflicts, and the solid built from it. + let (mut s, feature, params) = linkage_session(); + for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] { + run(&mut s, "sketch.line", json!({"points": line})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5})); + set_params(&mut s, ¶ms, &FLIPPING); + let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + let state = s.model.state(); + let solved = state.sketch(feature).unwrap(); + assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report); + let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default(); + assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}"); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::>()); + assert_eq!(state.bodies.len(), 1); + // Listed as conflicts wherever conflicts are shown. + let mut s = s; + s.active_sketch = Some(feature); + let si = run(&mut s, "sketch.inspect", json!({})); + let glyphs = run(&mut s, "sketch.glyphs", json!({})); + for id in &solved.report.folded { + assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}"); + assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}"); + } +} + +#[test] +fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() { + // `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving + // the guide out of reach is the guide sketch's edit, not a value edit of the projecting + // sketch: it applies, and the projecting sketch carries a warning. + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"})); + run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap(); + let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0] + .as_str() + .unwrap() + .to_string(); + let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12})); + run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected})); + run(&mut s, "sketch.finish", json!({})); + assert!(s.model.state().sketch(id).unwrap().report.ok()); + let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"})); + assert_eq!(r["value"], json!(20.0), "the edit applies: {r}"); + assert!(!s.model.state().sketch(id).unwrap().report.ok()); + assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict"))); +} + +#[test] +fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() { + // Auto-constrain works on the current values: reached by continuation, its result is + // stored solved for them; values no geometry satisfies refuse it and change nothing. + let consistent = |s: &Session, feature: u64| { + let stored = s.doc.sketch(feature).unwrap(); + stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| { + let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap(); + (measured - c.kind.value().unwrap()).abs() < 1e-6 + }) + }; + let (mut s, feature, params) = linkage_session(); + let mut values = REACHED_BY_CONTINUATION; + values[7] = 500.0; + set_params(&mut s, ¶ms, &values); + let before = s.doc.sketch(feature).unwrap().clone(); + let why = err(&mut s, "sketch.auto_constrain", json!({})); + assert!(why.contains("conflict"), "{why}"); + assert_eq!(*s.doc.sketch(feature).unwrap(), before); + assert!(consistent(&s, feature)); + set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION); + run(&mut s, "sketch.auto_constrain", json!({})); + assert!(consistent(&s, feature)); + let stored = s.doc.sketch(feature).unwrap(); + for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) { + assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id); + } +} + +/// The stored sketch's points, by id. +fn stored_points(s: &Session, feature: u64) -> Vec { + s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect() +} + +#[test] +fn an_unsolvable_value_edit_is_refused_and_changes_nothing() { + let (mut s, feature, params) = linkage_session(); + run(&mut s, "sketch.finish", json!({})); + let (expr, points) = (s.doc.param(¶ms[7]).map(|p| p.expr.clone()), stored_points(&s, feature)); + let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"})); + assert!(why.contains("Linkage") && why.contains("conflict"), "{why}"); + assert_eq!(s.doc.param(¶ms[7]).map(|p| p.expr.clone()), expr); + assert_eq!(stored_points(&s, feature), points); + assert!(s.model.state().sketch(feature).unwrap().report.ok()); + // A batch with one such value: none of it. + let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect(); + let batch: Vec<_> = params + .iter() + .enumerate() + .map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }})) + .collect(); + let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()})); + assert!(why.contains("Linkage"), "{why}"); + assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::>(), before); + assert_eq!(stored_points(&s, feature), points); +} + +#[test] +fn value_edits_continue_from_the_last_solved_state() { + // A → B → C, each a small edit solved in one jump; C is solved from B (the stored state), + // and setting both values back reproduces A (the linkage is fully constrained). + let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol); + let (mut s, feature, params) = linkage_session(); + run(&mut s, "sketch.finish", json!({})); + let a = stored_points(&s, feature); + let (p7, p8) = (s.doc.param(¶ms[7]).unwrap().expr.clone(), s.doc.param(¶ms[8]).unwrap().expr.clone()); + run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"})); + let b_sketch = s.doc.sketch(feature).unwrap().clone(); + let b = stored_points(&s, feature); + assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0); + run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"})); + let c = stored_points(&s, feature); + // The same edit solved from B directly. + let mut from_b = b_sketch.clone(); + let stored = from_b.driving_values(); + let (vals, _) = s.doc.param_values(); + s.doc.apply_dimension_values(&vals, &mut from_b).unwrap(); + assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok()); + assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::>(), 1e-6) && !close(&c, &b, 1e-3)); + run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7})); + run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8})); + assert!(close(&stored_points(&s, feature), &a, 1e-6)); +} + +#[test] +fn switching_configurations_back_reuses_the_later_features() { + // Rows A and B of `len`: back on A, the sketch solves again from B's state to A's + // geometry, and the extrude after it is restored from the cache. + let mut s = Session::default(); + block(&mut s); + run(&mut s, "config.configure", json!({"params": ["len"]})); + run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}})); + run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}})); + run(&mut s, "config.activate", json!({"row": "B"})); + assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9); + let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0); + let before = hits(&s); + let r = run(&mut s, "config.activate", json!({"row": "A"})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); + assert!(hits(&s) > before, "{r}"); +} diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index be7fe0eb..58c8d273 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params( "name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool", ), - CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"), + CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"), CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern) .at("SHEET METAL", "CREATE") .icon("flat") diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 53860a24..6d68b693 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind}; use solvecraft_geom::{Plane, Vec2, Vec3}; -use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding}; +use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with}; use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; @@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session}; #[path = "sketch_dimensioned.rs"] mod dimensioned; +#[path = "sketch_drag.rs"] +mod drag; #[path = "sketch_face.rs"] pub(super) mod face; use dimensioned::draw as draw_dimensioned; +use drag::drag_entity; /// The `connect` parameter every drawing command takes (see `connect_points`). macro_rules! connect_doc { @@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), - CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params( - "entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can", + CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params( + "entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag", + ), + CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params( + "constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom", ), CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text) .enabled(in_sketch) @@ -219,25 +225,143 @@ pub(super) fn edit( f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result, ) -> Result<(T, Value)> { let id = target_sketch(s, p, cmd)?; + let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?; + Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null))) +} + +/// A solve's outcome as commands return it. +pub(super) fn report_json(rep: &SolveReport) -> Value { + json!({"solved": rep.keeps_orientation(), "dof": rep.dof}) +} + +/// What an [`edit_solved`] step leaves for the edit's own solve. +pub(super) enum Left { + /// The sketch, solved from where it is. + Unsolved, + /// The sketch, solved keeping the orientation of these positions (the last solved ones, + /// before a drag left a step placed but unsolved). + MovedFrom(Vec), + /// The sketch, solved with the dimensions' values (from the parameters) applied. + Solved, +} + +impl Left { + /// Solved again by the edit even when already solved (for its report). + pub(super) fn solve_again(self) -> Left { + match self { + Left::Solved => Left::Unsolved, + left => left, + } + } +} + +/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns +/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the +/// report is `None`. +pub(super) fn edit_solved( + s: &mut Session, + id: u64, + strict: bool, + f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>, +) -> Result<(T, Option)> { let mut doc = (*s.doc).clone(); let mut sk = doc.sketch(id)?.clone(); super::sketch_project::refresh(s, &doc, id, &mut sk); - let out = f(&mut sk, &mut doc)?; - let (vals, _) = doc.param_values(); - doc.apply_dimension_values(&vals, &mut sk)?; - sk.refresh_offsets()?; - let mut rep = solve(&mut sk); - if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { - sk.refresh_offsets()?; - rep = solve(&mut sk); - } - if strict && !rep.ok() { - return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", ")))); - } + // The stored sketch is solved for its stored values. + let stored = sk.driving_values(); + let (out, left) = f(&mut sk, &mut doc)?; + let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty(); + let rep = match left { + // A drag step solved it (held, with the dimensions' values applied). + Left::Solved if !offsets => None, + // A drag left a step placed but unsolved: solve it keeping the orientation of the last + // solved positions. A drag changes no values, so there is nothing to continue. + Left::MovedFrom(reference) => { + doc.apply_dimension_values(&doc.param_values().0, &mut sk)?; + sk.refresh_offsets()?; + let unsolved = sk.clone(); + let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None); + if offsets { + sk.refresh_offsets()?; + rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None); + } + Some(settle(strict, rep, &mut sk, unsolved)?) + } + // Solved for the document's values, continuing from the stored solved state. + Left::Solved | Left::Unsolved => { + let (vals, _) = doc.param_values(); + sk.refresh_offsets()?; + let unsolved = sk.clone(); + #[cfg(test)] + solve_counts::record(Analysis::Full); + let rep = doc.solve_sketch(&vals, &mut sk, &stored)?; + Some(settle(strict, rep, &mut sk, unsolved)?) + } + }; sk.validate_periodic_splines()?; *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; - Ok((out, json!({"solved": rep.ok(), "dof": rep.dof}))) + Ok((out, rep)) +} + +/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an +/// error; otherwise such a sketch is stored as the edit left it before the solve, its values +/// and geometry together, so the stored sketch stays solved for its stored values. +fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result { + if strict && !rep.keeps_orientation() { + return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})")))); + } + if !rep.keeps_orientation() { + *sk = unsolved; + } + Ok(rep) +} + +/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]). +pub(super) fn counted_solve( + sk: &mut Sketch, + hold: &Hold, + reference: Option<&[Vec2]>, + analysis: Analysis, + budget: Option<&mut Budget>, +) -> SolveReport { + #[cfg(test)] + solve_counts::record(analysis); + solve_with(sk, hold, reference, analysis, budget) +} + +/// Inside an `edit` closure: solve the sketch for the document's parameter values from its +/// stored solved state, as the edit's own solve does, for commands that work on the current +/// geometry. +pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result { + let stored = sk.driving_values(); + let (values, _) = doc.param_values(); + Ok(doc.solve_sketch(&values, sk, &stored)?) +} + +/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those +/// with a rank analysis). +#[cfg(test)] +pub(super) mod solve_counts { + use std::cell::Cell; + + use solvecraft_sketch::Analysis; + + thread_local! { + static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) }; + } + + pub fn record(analysis: Analysis) { + COUNTS.with(|c| { + let (n, full) = c.get(); + c.set((n + 1, full + usize::from(analysis == Analysis::Full))); + }); + } + + /// The counts since the last call. + pub fn take() -> (usize, usize) { + COUNTS.with(|c| c.replace((0, 0))) + } } /// How close (mm) a new point must land to an existing one to be connected to it. @@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], driven: false, text: None, })); - let ok = solve_holding(&mut trial, &h).ok(); + let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok(); trial.constraints.retain(|c| c.id != TEMP); if ok && !collapsed(sk, &trial) { *sk = trial; @@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result { && let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a))) { let rep = solve(&mut sk.clone()); - return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}})); + return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)})); } constrain(s, p, cmd, |sk| { if let Some((a, b)) = line_points { @@ -1823,109 +1947,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result { fn move_point(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.move_point"; let to = req_vec2(cmd, p, "to")?; - let (_, info) = edit(s, p, cmd, false, |sk, _| { + let id = target_sketch(s, p, cmd)?; + let (_, rep) = edit_solved(s, id, false, |sk, _| { let q = point_ref(sk, p.get("point"), cmd, "point")?; - drag_entity(sk, Ent::Point(q), to, to); - Ok(()) + Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again())) })?; - Ok(json!({"sketch": info})) + Ok(json!({"sketch": rep.as_ref().map(report_json)})) } fn drag(s: &mut Session, p: &Value) -> Result { - let cmd = "sketch.drag"; - let to = req_vec2(cmd, p, "to")?; - let from = p.get("from").and_then(vec2).unwrap_or(to); - let (_, info) = edit(s, p, cmd, false, |sk, _| { - let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?; - let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?; - drag_entity(sk, e, from, to); - Ok(()) - })?; - Ok(json!({"sketch": info})) + drag_frame(s, p, false).map(|(v, _)| v) } -/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line, -/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes -/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and -/// fully constrained geometry stays (the solve that follows pulls it back). -fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) { - let d = to - from; - let mut hold = Hold::default(); - match e { - Ent::Point(q) => { - if sk.point_locked(q) { - return; - } - if let Some(pt) = sk.points.get_mut(q) { - pt.pos = to; - } - hold.points.push(q); - } - Ent::Curve(c) => { - if sk.curve_locked(c) { - return; - } - let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return }; - let locked = sk.locked_points(); - let set = |sk: &mut Sketch, q: usize, at: Vec2| { - if !locked.get(q).copied().unwrap_or(true) - && let Some(pt) = sk.points.get_mut(q) - { - pt.pos = at; - } - }; - match kind { - CurveKind::Circle { c: cc, .. } => { - let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0); - if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind) - && r > 1e-9 - { - *slot = r; - } - hold.points.push(cc); - hold.radii.push(c); - } - CurveKind::Arc { c: cc, a, b } => { - let Some(o) = sk.point(cc) else { return }; - let r = o.dist(to); - if r > 1e-9 { - for q in [a, b] { - if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) { - set(sk, q, o + u * r); - } - } - } - hold.points.extend([cc, a, b]); - } - CurveKind::Ellipse { c: cc, m, .. } => { - // Grabbed on the outline: the minor radius follows (the major stays). - let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return }; - let u = (pm - o).normalized().unwrap_or(Vec2::X); - let minor = u.cross(to - o).abs(); - if minor > 1e-9 - && let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind) - { - *slot = minor; - } - hold.points.extend([cc, m]); - hold.radii.push(c); - } - k => { - // Lines, splines and conics slide with the cursor. - for q in k.point_ids().into_iter().collect::>() { - if let Some(at) = sk.point(q) { - set(sk, q, at + d); - } - } - hold.points.extend(k.point_ids()); - } - } - } +fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option)> { + drag_frame(s, p, true) +} + +/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show. +fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option)> { + let cmd = "sketch.drag"; + let to = req_vec2(cmd, p, "to")?; + let from = match p.get("from") { + None | Some(Value::Null) => to, + Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?, + }; + let id = target_sketch(s, p, cmd)?; + // The sketch's report as last evaluated: a live frame keeps its degrees of freedom. + let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone()); + let (d, rep) = edit_solved(s, id, false, |sk, doc| { + let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?; + let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?; + // The held solves work on the dimensions' current values (a solved step applies them). + doc.apply_dimension_values(&doc.param_values().0, sk)?; + let (d, left) = drag_entity(sk, e, from, to, live); + // A live frame's held solve is its only one; otherwise the sketch's own solve reports + // its degrees of freedom. + Ok((d, if live && last.is_some() { left } else { left.solve_again() })) + })?; + // Where the grabbed spot went: `from` moved as the dragged points did on average (with the + // whole step when it followed; otherwise as the sketch's own solve left them). + let sk = s.doc.sketch(id)?; + let moves: Vec = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect(); + let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64); + let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last })); + let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]}); + Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report }))) +} + +/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model. +/// It reads the model only before the sketch it edits (never stale), so `execute` does not +/// evaluate first. +fn drag_end(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.drag_end"; + let id = target_sketch(s, p, cmd)?; + let start = s.doc.clone(); + let mut constraint = Value::Null; + if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) { + let name = c.get("command").and_then(Value::as_str).unwrap_or_default(); + let spec = super::find_command(name) + .filter(|spec| spec.id.starts_with("sketch.constraint.")) + .ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?; + let params = c.get("params").cloned().unwrap_or(Value::Null); + // A constraint that cannot be added (it would over-constrain the sketch) is not kept. + constraint = spec.run_nested(s, ¶ms).unwrap_or_else(|e| { + s.doc = start.clone(); + json!({"error": e.to_string()}) + }); } - let mut trial = sk.clone(); - if solve_holding(&mut trial, &hold).ok() { - *sk = trial; + // A new constraint changed the document: `execute` evaluates the model after this. + if !std::sync::Arc::ptr_eq(&start, &s.doc) { + return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")})); } + if s.model.is_stale() { + s.refresh(); + } + let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report)); + Ok(json!({"constraint": constraint, "sketch": report})) } /// The Sketch Palette options of a sketch, by their palette names (true = shown / on). @@ -2000,6 +2097,9 @@ mod rect_tests; #[path = "sketch_ux_tests.rs"] mod ux_tests; +#[cfg(test)] +#[path = "sketch_drag_live_tests.rs"] +mod drag_live_tests; #[cfg(test)] #[path = "sketch_ids_tests.rs"] mod ids_tests; diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index d95ec5a0..670f8d09 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result { Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?, None => 0, }; - // Values of existing dimensions must be applied before judging degrees of freedom. - let (vals, _) = doc.param_values(); - doc.apply_dimension_values(&vals, sk)?; + // Values of existing dimensions must be applied (and reached from the stored solved + // state) before judging degrees of freedom; a sketch they would turn over keeps its + // stored geometry, as the edit's own solve then refuses them. + let rep = super::sketch::solve_current(sk, doc)?; + if !rep.keeps_orientation() { + return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})")))); + } Ok(run_auto(sk, doc, p, datum)) })?; Ok(json!({"added": added, "dof": dof, "sketch": info})) diff --git a/crates/engine/src/cmd/sketch_drag.rs b/crates/engine/src/cmd/sketch_drag.rs new file mode 100644 index 00000000..0c82a2fd --- /dev/null +++ b/crates/engine/src/cmd/sketch_drag.rs @@ -0,0 +1,545 @@ +//! Dragging sketch geometry without jumping to another solution. +//! +//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's +//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded +//! on the positions from before it: the points the solver's construction places (from distances +//! and alignments to placed points) keep the orientation they had there however far the step. +//! The rest is left to the iteration, which from far away can land on any solution, so a long +//! drag still walks from where the geometry is to where the cursor puts it in short steps, each +//! solved from the previous step's result (continuation). Continuation is what keeps lengths the +//! construction does not read (an Equal to another line, a rigid shape hanging off a held point); +//! it also lets the arc sweep guard see every step, and lets a path that crosses where the +//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only +//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve. + +use std::f64::consts::{PI, TAU}; + +use solvecraft_geom::Vec2; +use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch}; + +use super::{Ent, Left, counted_solve}; + +/// Longest step, as a fraction of the smallest driving length or radius of the dragged group. +/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch +/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across +/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start): +/// this keeps a factor of four. It holds where the seed placed what it could: once a held +/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag +/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch. +const STEP_FRACTION: f64 = 0.25; +/// The steps of a drag whose seed did not place everything it could (as before seeding steps). +const SHORT_STEP_FRACTION: f64 = 0.125; +/// Shortest step (mm), so a tiny feature cannot make a drag crawl. +const MIN_STEP: f64 = 1e-3; +/// Most steps one drag is split into. +const MAX_STEPS: usize = 64; +/// After a step fails, the way to it from the last solved state is halved until what is left is +/// shorter than a step over this; at most `MAX_STEPS` such solves per drag. +const HALVINGS: usize = 16; +/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start. +const SWEEP_EPS: f64 = 1e-6; + +/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`). +enum Path { + /// Points slide by `d` (a dragged point by its way to the cursor). + Slide { pts: Vec<(usize, Vec2)>, d: Vec2 }, + /// A circle's radius or an ellipse's minor radius follows the cursor. + Radius { c: usize, from: f64, to: f64 }, + /// An arc's ends move along their rays from the centre `o` to the cursor's radius. + ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 }, +} + +impl Path { + fn length(&self) -> f64 { + match self { + Path::Slide { d, .. } => d.len(), + Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(), + } + } + + /// The points the drag moves. + fn points(&self) -> impl Iterator + '_ { + let pts = match self { + Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(), + Path::Radius { .. } => &[], + }; + pts.iter().map(|(q, _)| *q) + } + + /// Put the dragged geometry where it is at `t` along the drag. + fn apply(&self, sk: &mut Sketch, t: f64) { + let mut set = |q: usize, at: Vec2| { + if let Some(pt) = sk.points.get_mut(q) { + pt.pos = at; + } + }; + match self { + Path::Slide { pts, d } => { + for &(q, at) in pts { + set(q, at + *d * t); + } + } + Path::ArcRadius { o, ends, from, to } => { + let r = from + (to - from) * t; + for &(q, u) in ends { + set(q, *o + u * r); + } + } + Path::Radius { c, from, to } => { + if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) { + *r = from + (to - from) * t; + } + } + } + } +} + +/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line, +/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes +/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the +/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity +/// held (the path crosses where the geometry cannot reach) is approached by halving the way to +/// it, and the next steps are tried from the last solved state, the last of them being the whole +/// remaining move. When none past it solves, the first failed step stays placed but unsolved: +/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see +/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch +/// unsolved for the user to see. A step that would sweep an arc through its own start is undone +/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve +/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved). +/// +/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition +/// budget ([`Budget::frame`]); otherwise each has a solve's own. +pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) { + let Some((path, hold, n, short)) = prepare(sk, e, from, to) else { + return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved); + }; + #[cfg(test)] + let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get())); + let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect(); + let mut frame = live.then(frame_budget); + // Only success matters to these solves: the degrees of freedom of a sketch with the entity + // held are not the sketch's. Each is seeded on the last solved state (`last`), so what the + // constraints pin down keeps the orientation it had there. A failed or wrapping step is + // undone (a failed solve leaves the step placed). Also returns whether the decomposition ran + // out of budget. + let mut solves = Solves::default(); + let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| { + path.apply(sk, t); + let mut own = Budget::full(); + let budget = frame.as_mut().unwrap_or(&mut own); + let spent = budget.spent(); + let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok(); + solves.count += 1; + solves.work += budget.spent() - spent; + solves.cut |= budget.exhausted(); + let outcome = if !ok { + Step::Failed + } else if last.sweep_wrapped(sk) { + Step::Wrapped + } else { + return Step::Solved; + }; + last.restore(sk); + outcome + }; + let mut last = Geometry::default(); + last.take(sk); + // How far along the last solved state is; the first step since it that failed. + let (mut good, mut failed) = (0.0, None); + let mut spare = MAX_STEPS; + // The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in + // short steps. + let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0); + let mut shortened = false; + 'walk: while t < 1.0 { + if solves.cut && !shortened { + shortened = true; + let rest = 1.0 - t; + let m = steps(rest * short as f64, 1.0); + (base, dt, k) = (t, rest / m as f64, 0); + } + k += 1; + t = base + dt * k as f64; + // The last step ends exactly at the cursor. + if 1.0 - t < 0.5 * dt { + t = 1.0; + } + match step(sk, &last, t, &mut solves) { + Step::Solved => { + (good, failed) = (t, None); + if t < 1.0 { + last.take(sk); + } + continue; + } + Step::Wrapped => { + failed = None; + break 'walk; + } + Step::Failed => {} + } + if failed.is_none() { + // Halve the way to the failed step, so the state the next steps start from (and are + // seeded on) is as close to it as the geometry follows. The shortest move is tried + // first: geometry that cannot move at all (pinned) costs one solve, not every halving. + let fine = dt / HALVINGS as f64; + let mut miss = t; + let mut first = true; + while spare > 0 && miss - good > fine { + spare -= 1; + let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) }; + match step(sk, &last, mid, &mut solves) { + Step::Solved => { + good = mid; + last.take(sk); + } + Step::Wrapped => break 'walk, + Step::Failed => miss = mid, + } + } + failed = Some(t); + } + } + #[cfg(test)] + last_drag::set(solves); + let left = match failed { + // The failed step is left placed for the edit's solve, seeded on the last solved state. + Some(t) => { + path.apply(sk, t); + Left::MovedFrom(last.pos) + } + None if good > 0.0 => Left::Solved, + None => Left::Unsolved, + }; + (Dragged { reached: good, moving }, left) +} + +/// The held solves of one drag: how many, the decomposition work they did, and whether one ran +/// out of budget. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Solves { + pub count: usize, + pub work: usize, + pub cut: bool, +} + +/// One live frame's decomposition budget (smaller in tests that set one). +fn frame_budget() -> Budget { + #[cfg(test)] + if let Some(work) = last_drag::frame_work() { + return Budget::new(work); + } + Budget::frame() +} + +/// What the last drag on this thread did, and a frame budget for tests to set. +#[cfg(test)] +pub(super) mod last_drag { + use std::cell::Cell; + + use super::Solves; + + thread_local! { + static LAST: Cell = const { Cell::new(Solves { count: 0, work: 0, cut: false }) }; + static FRAME: Cell> = const { Cell::new(None) }; + } + + pub fn set(s: Solves) { + LAST.with(|c| c.set(s)); + } + + /// The held solves of the last drag. + pub fn get() -> Solves { + LAST.with(Cell::get) + } + + pub fn frame_work() -> Option { + FRAME.with(Cell::get) + } + + /// Restores the frame budget when dropped, also when the test panics. + struct Restore(Option); + + impl Drop for Restore { + fn drop(&mut self) { + FRAME.with(|c| c.set(self.0)); + } + } + + /// Run `f` with live frames given `work` units of decomposition budget. + pub fn with_frame_work(work: usize, f: impl FnOnce() -> R) -> R { + let _restore = Restore(FRAME.with(|c| c.replace(Some(work)))); + f() + } +} + +/// How one held solve of a drag went. +enum Step { + Solved, + Failed, + /// Solved, but it swept an arc through its own start. + Wrapped, +} + +/// How far a drag got. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Dragged { + /// The part of the way from where it was grabbed to the cursor the geometry followed (0..1). + pub reached: f64, + /// The points the drag moves (not the fixed ones it holds), where they were before it. + pub moving: Vec<(usize, Vec2)>, +} + +/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve +/// may swap), kept to undo a step. Its buffers are reused from step to step. +#[derive(Default)] +struct Geometry { + pos: Vec, + curves: Vec<(CurveKind, bool)>, +} + +impl Geometry { + fn take(&mut self, sk: &Sketch) { + self.pos.clear(); + self.pos.extend(sk.points.iter().map(|p| p.pos)); + self.curves.clear(); + self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed))); + } + + fn restore(&self, sk: &mut Sketch) { + for (p, at) in sk.points.iter_mut().zip(&self.pos) { + p.pos = *at; + } + for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) { + c.kind = kind.clone(); + c.reversed = *reversed; + } + } + + /// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full + /// turn, or ran from clear of them onto one? An arc already that close may still move. + /// Measured on the arc's ends as they were before the step (a solve may swap them). + fn sweep_wrapped(&self, sk: &Sketch) -> bool { + let was = |q: usize| self.pos.get(q).copied(); + let now = |q: usize| sk.point(q); + let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s); + self.curves.iter().any(|(kind, _)| { + let CurveKind::Arc { c, a, b } = *kind else { return false }; + let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false }; + (s1 - s0).abs() > PI || (clear(s0) && !clear(s1)) + }) + } +} + +/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan +/// `drag_entity` runs, for tests. +#[cfg(test)] +pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize { + prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n) +} + +/// A drag's path, what its solves hold and its number of steps (and of short steps, see +/// `SHORT_STEP_FRACTION`), or `None` if it cannot move. +fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> { + if !(from.is_finite() && to.is_finite()) { + return None; + } + let (path, hold) = plan(sk, e, from, to)?; + let n = step_count(sk, &hold, path.length(), STEP_FRACTION); + let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION); + Some((path, hold, n, short)) +} + +/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer +/// steps keep (the planned count, `drag_steps`, is not capped). +#[cfg(test)] +pub(super) mod step_cap { + use std::cell::Cell; + + thread_local! { + static CAP: Cell = const { Cell::new(super::MAX_STEPS) }; + } + + pub fn get() -> usize { + CAP.with(Cell::get) + } + + /// Restores the cap when dropped, also when the test panics. + struct Restore(usize); + + impl Drop for Restore { + fn drop(&mut self) { + CAP.with(|c| c.set(self.0)); + } + } + + /// Run `f` with drags split into at most `cap` steps (at least one). + pub fn with(cap: usize, f: impl FnOnce() -> R) -> R { + let _restore = Restore(CAP.with(|c| c.replace(cap.max(1)))); + f() + } +} + +/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`. +fn steps(length: f64, step: f64) -> usize { + let n = (length / step).ceil(); + if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 } +} + +/// The dragged geometry's path and what the solves hold, or `None` if it cannot move. +fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> { + let mut hold = Hold::default(); + let path = match e { + Ent::Point(q) => { + if sk.point_locked(q) { + return None; + } + hold.points.push(q); + let at = sk.point(q)?; + Path::Slide { pts: vec![(q, at)], d: to - at } + } + Ent::Curve(c) => { + if sk.curve_locked(c) { + return None; + } + let kind = sk.curves.get(c).map(|x| x.kind.clone())?; + let locked = sk.locked_points(); + let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true); + // A cursor on the centre leaves the radius as it is. + let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r); + match kind { + CurveKind::Circle { c: cc, r } => { + let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r)); + hold.points.push(cc); + hold.radii.push(c); + Path::Radius { c, from: r, to: to_r } + } + CurveKind::Arc { c: cc, a, b } => { + let o = sk.point(cc)?; + let r = sk.radius(c).unwrap_or(0.0); + hold.points.extend([cc, a, b]); + let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect(); + Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) } + } + CurveKind::Ellipse { c: cc, m, r } => { + // Grabbed on the outline: the minor radius follows (the major stays). + let (o, pm) = (sk.point(cc)?, sk.point(m)?); + let u = (pm - o).normalized().unwrap_or(Vec2::X); + let minor = u.cross(to - o).abs(); + hold.points.extend([cc, m]); + hold.radii.push(c); + Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } } + } + k => { + // Lines, splines and conics slide with the cursor. + let mut ids = k.point_ids(); + ids.sort_unstable(); + ids.dedup(); + let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect(); + hold.points.extend(ids); + Path::Slide { pts, d: to - from } + } + } + } + }; + Some((path, hold)) +} + +/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest +/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most +/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one +/// step, found without grouping the sketch. +fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize { + let step = |size: f64| (size * fraction).max(MIN_STEP); + if length <= step(smallest_size(sk, hold, None)) { + return 1; + } + let (points, radii) = sk.coupled(&hold.points, &hold.radii); + steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii })))) +} + +/// What a solve moves together: per point and per curve radius. +#[derive(Clone, Copy)] +struct Group<'a> { + points: &'a [bool], + radii: &'a [bool], +} + +impl Group<'_> { + /// Does an entity with these points and curves belong to the group? + fn has(&self, points: &[usize], curves: &[usize]) -> bool { + let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false)); + any(self.points, points) || any(self.radii, curves) + } +} + +/// The smallest size that can make a constrained shape take another solution (of the geometry +/// in `group`, or of the whole sketch), or infinity when there is none: every length the +/// constraints hold. That is driving length dimensions (offsets along an axis or from a line +/// too: the solver holds their size, either side), radii and the current lengths of lines held +/// equal to another (a link's length can come from an Equal as well as a dimension). A line that +/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn +/// over, so it does not shrink the steps. Nor does a distance between two held points (the +/// dragged line's own length): held geometry moves as one. A circle's radius counts when the +/// solve moves the radius itself (not merely its centre). +fn smallest_size(sk: &Sketch, hold: &Hold, group: Option) -> f64 { + // Point lists are built only to test a size smaller than the smallest so far against the + // group; without a group nothing is built. + let in_group = |points: &dyn Fn() -> Vec, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves)); + // A radius is the circle's own variable, or (an arc's) the distance between its points. + let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) { + Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]), + Some(k) => in_group(&|| k.point_ids(), &[]), + None => false, + }; + let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b)); + let points = |k: &ConstraintKind| sk.constraint_points(k); + let mut min = f64::INFINITY; + let mut take = |size: f64, counts: &dyn Fn() -> bool| { + let size = size.abs(); + if size.is_finite() && size > 1e-9 && size < min && counts() { + min = size; + } + }; + for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) { + match *k { + ConstraintKind::Equal { a, b } => { + if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) { + take(v, &|| in_group(&|| points(k), &[])); + } + } + ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)), + ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)), + _ => { + if let Some(v) = driving_length(k) { + // Held points move as one: a length between them never changes. + take(v, &|| { + let pts = points(k); + !pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1) + }); + } + } + } + } + for c in 0..sk.curves.len() { + if let Some(r) = sk.radius(c) { + take(r, &|| radius_in_group(c)); + } + } + min +} + +/// The length a driving dimension sets (a linear diameter's radius), if it sets one. +fn driving_length(k: &ConstraintKind) -> Option { + if k.is_angle() { + return None; + } + let v = k.value()?; + Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v }) +} + +/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`. +fn sweep(c: Option, a: Option, b: Option) -> Option { + let (o, pa, pb) = (c?, a?, b?); + Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU)) +} diff --git a/crates/engine/src/cmd/sketch_drag_live_tests.rs b/crates/engine/src/cmd/sketch_drag_live_tests.rs new file mode 100644 index 00000000..e13670a4 --- /dev/null +++ b/crates/engine/src/cmd/sketch_drag_live_tests.rs @@ -0,0 +1,290 @@ +//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a +//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +use super::solve_counts; +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again; +/// returns the session and the extrude's id. +fn extruded() -> (Session, u64) { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude"); + run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"})); + (s, extrude) +} + +/// Counters from here on. +fn reset(s: &mut Session) { + s.evaluations = 0; + solve_counts::take(); +} + +/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each. +fn frames(s: &mut Session, n: usize, live: bool) -> Vec { + (1..=n) + .map(|k| { + let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]); + run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live})) + }) + .collect() +} + +fn body_contains(s: &Session, p: Vec3) -> bool { + s.model.state().bodies.first().expect("a body").body.contains(p) +} + +fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 { + let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode"); + sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point") +} + +fn corner(s: &Session) -> solvecraft_geom::Vec2 { + let id = s.active_sketch.expect("sketch mode"); + let st = s.world_state(); + let sk = &st.sketch(id).expect("the shown sketch").sketch; + sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner") +} + +#[test] +fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() { + let (mut s, extrude) = extruded(); + let body = s.model.state().bodies.first().expect("a body").body.clone(); + reset(&mut s); + let r = frames(&mut s, 1, true).remove(0); + assert_eq!(s.evaluations, 0, "a live frame evaluates no feature"); + assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis"); + assert_eq!(r["reached"], json!(1.0)); + assert_eq!(r["sketch"]["solved"], json!(true)); + assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag"); + // The sketch shows its new place; the extrude keeps its last result. + assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9); + let result = s.model.result(extrude).expect("the extrude's result"); + assert!(result.stale); + assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt"); +} + +#[test] +fn the_drag_end_evaluates_once_to_the_scripted_drags_result() { + let (mut live, extrude) = extruded(); + let (mut scripted, _) = extruded(); + frames(&mut live, 3, true); + reset(&mut live); + let end = run(&mut live, "sketch.drag_end", json!({})); + assert_eq!(live.evaluations, 1, "one full evaluation at the end"); + assert_eq!(solve_counts::take(), (0, 0)); + assert_eq!(end["sketch"], json!({"solved": true, "dof": 2})); + assert!(!live.model.is_stale()); + frames(&mut scripted, 3, false); + assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`"); + let (a, b) = (live.model.state(), scripted.model.state()); + let (sa, sb) = (&a.sketches[0], &b.sketches[0]); + assert_eq!(sa.sketch, sb.sketch); + assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined)); + assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed"); + assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none())); + // One undo step for the gesture is the UI's business; each frame is still undoable. + run(&mut live, "edit.undo", json!({})); + assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9); +} + +#[test] +fn an_abandoned_live_drag_is_evaluated_before_the_next_command() { + let (mut s, extrude) = extruded(); + frames(&mut s, 2, true); + assert!(s.model.is_stale()); + reset(&mut s); + // A command that changes nothing still sees (and leaves) a fully evaluated model. + run(&mut s, "sketch.solve", json!({})); + assert_eq!(s.evaluations, 1); + assert!(!s.model.is_stale()); + assert!(s.model.result(extrude).is_some_and(|r| !r.stale)); + assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5))); + // Leaving the sketch does too. + frames(&mut s, 1, true); + run(&mut s, "sketch.finish", json!({})); + assert!(!s.model.is_stale()); +} + +#[test] +fn a_scripted_drag_still_evaluates_the_whole_model() { + let (mut s, extrude) = extruded(); + reset(&mut s); + let r = frames(&mut s, 1, false).remove(0); + assert_eq!(s.evaluations, 1); + assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis"); + assert_eq!(r["sketch"], json!({"solved": true, "dof": 2})); + assert!(!s.model.is_stale()); + assert!(s.model.result(extrude).is_some_and(|r| !r.stale)); + assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5))); +} + +#[test] +fn a_drag_reports_how_far_the_geometry_followed() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]})); + run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + for live in [false, true] { + // A line of fixed length with a fixed end cannot slide: the sketch's own solve only + // turns it a little toward the cursor, and the grab follows its free end. + let before = pt(&s, "l1.end"); + let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live})); + assert_eq!(r["reached"], json!(0.0), "{r}"); + let moved = pt(&s, "l1.end") - before; + let at = crate::params::vec2(&r["at"]).expect("`at`"); + assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}"); + let to = 40 + 10 * usize::from(live); + let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]})); + assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}"); + } + // A point that cannot move at all. + let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true})); + assert_eq!(r["reached"], json!(0.0)); +} + +#[test] +fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})); + reset(&mut s); + frames(&mut s, 2, true); + assert_eq!(s.evaluations, 0); + assert_eq!(solve_counts::take(), (2, 0)); + // The free rectangle's corner moves alone; its profile follows. + assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9); + let st = s.model.state(); + let profile = st.sketches[0].profiles.first().expect("a profile"); + assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area); + run(&mut s, "sketch.drag_end", json!({})); + assert_eq!(s.evaluations, 1); +} + +/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each +/// frame's `from` is the previous frame's `at`, as the viewport does. +fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) { + let (mut from, mut cursor) = (grab, grab); + for m in moves { + cursor = [cursor[0] + m[0], cursor[1] + m[1]]; + let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true})); + let at = crate::params::vec2(&r["at"]).expect("`at`"); + from = [at.x, at.y]; + } +} + +#[test] +fn the_grab_follows_the_points_that_move() { + // A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the + // cursor's offset frame after frame. + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]); + assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end")); + assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12); +} + +#[test] +fn a_query_during_a_live_drag_does_not_evaluate_the_model() { + let (mut s, _) = extruded(); + frames(&mut s, 1, true); + reset(&mut s); + for (id, p) in + [("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))] + { + run(&mut s, id, p); + } + assert_eq!(s.evaluations, 0); + assert!(s.model.is_stale(), "the drag goes on"); + frames(&mut s, 1, true); + assert_eq!(s.evaluations, 0); +} + +fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch { + s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode") +} + +#[test] +fn a_live_frame_solves_the_dimensions_current_values() { + let (mut s, _) = extruded(); + // The bottom side's parameter now says 25 while the sketch still holds 20 mm. + let sk = sketch_of(&s); + let l1 = sk.curve_index("l1").expect("l1"); + let param = sk + .constraints + .iter() + .find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1)) + .and_then(|c| c.param.clone()) + .expect("l1's dimension"); + if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) { + p.expr = "25".into(); + } + let r = frames(&mut s, 1, true).remove(0); + assert_eq!(r["sketch"]["solved"], json!(true)); + let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end")); + assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b)); +} + +#[test] +fn a_live_frame_swaps_only_the_dragged_sketch() { + let (mut s, _) = extruded(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ"})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"})); + let id = s.active_sketch.expect("sketch mode"); + let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch"); + let before = other(&s); + frames(&mut s, 1, true); + assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied"); + // Later frames change the state they own in place. + let state = std::sync::Arc::as_ptr(&s.model.state()); + frames(&mut s, 1, true); + assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state())); +} + +#[test] +fn letting_go_on_a_snap_or_undoing_evaluates_once() { + let (mut s, _) = extruded(); + run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false})); + let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point"); + frames(&mut s, 3, true); + reset(&mut s); + let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}}); + let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap })); + assert_eq!(s.evaluations, 1, "{r}"); + assert!(r["constraint"].get("error").is_none(), "{r}"); + assert!(!s.model.is_stale()); + // Undo after an abandoned drag. + frames(&mut s, 1, true); + reset(&mut s); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.evaluations, 1); + assert!(!s.model.is_stale()); + // A snap that over-constrains the sketch is not kept; the drag still ends. + frames(&mut s, 1, true); + let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}}); + let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad })); + assert!(r["constraint"]["error"].is_string(), "{r}"); + assert!(!s.model.is_stale()); +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 540bda2d..365f2a0c 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel; use solvecraft_geom::Vec2; use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections}; -use super::sketch::{add_c, edit, ids_of}; +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}; @@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.extend"; let at = req_vec2(cmd, p, "at")?; let (out, info) = edit(s, p, cmd, true, |sk, doc| { - let (values, _) = doc.param_values(); - doc.apply_dimension_values(&values, sk)?; - if !solvecraft_sketch::solve(sk).ok() { - return Err(bad(cmd, "solve the existing sketch constraints before extending")); + let 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"))?; @@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result { pin(sk, end, oi)?; let before = sk.points.iter().map(|p| p.pos).collect::>(); let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() }; - if !solvecraft_sketch::solve_holding(sk, &hold).ok() + if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_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) { @@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result { let side = p.get("side").and_then(vec2); let use_chain = bool_(p, "chain").unwrap_or(true); let (out, info) = edit(s, p, cmd, true, |sk, doc| { - let (values, _) = doc.param_values(); - doc.apply_dimension_values(&values, sk)?; sk.refresh_offsets()?; - if !solvecraft_sketch::solve(sk).ok() { - return Err(bad(cmd, "solve existing constraints before offsetting")); + 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 sel: Vec = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::>()?; @@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result { // A parameter edit may have changed the evaluated source without // rewriting the stored fit points. Break must freeze the displayed, // current dimensioned geometry, rather than its stale stored predecessor. - let (values, _) = doc.param_values(); - doc.apply_dimension_values(&values, sk)?; - if !solvecraft_sketch::solve(sk).ok() { - return Err(bad(cmd, "cannot break an offset with conflicting source dimensions")); - } - sk.refresh_offsets()?; - if !solvecraft_sketch::solve(sk).ok() { - return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions")); + 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 diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 007f9ed9..3ef20295 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() { assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}"); } +/// How the arm's link lengths are set. +#[derive(Clone, Copy, Debug)] +enum Links { + /// Length dimensions on both links. + Dimensioned, + /// Equal to fixed reference lines (no length dimension anywhere). + EqualToFixed, +} + +/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged +/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The +/// elbow has two solutions for every hand position; a drag must keep the one it started on. +/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short). +fn arm_drags(links: Links) -> (usize, usize) { + use std::f64::consts::TAU; + let (a, b) = (30.0, 20.0); + let (lo, hi) = ((a - b) + 3.0, a + b - 3.0); + let mut s = new_sketch(); + // Away from the sketch origin so nothing connects to it. + let o = v(200.0, 100.0); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + match links { + Links::Dimensioned => { + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b})); + } + Links::EqualToFixed => { + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]})); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]})); + run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"})); + run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"})); + } + } + let id = s.active_sketch.expect("sketch"); + let sk0 = sketch(&s); + assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned)); + let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}")); + let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end")); + let p2b = ix("l2.start"); + let side = |sk: &Sketch| { + let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos); + (x3 - x1).cross(x2 - x1).signum() + }; + let mut seed = 11u64; + let mut rnd = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let (mut kept, mut flips, mut short) = (0, 0, 0); + while kept < 1000 { + let th1 = rnd() * TAU; + let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 }; + let v2 = o + v(a * th1.cos(), a * th1.sin()); + let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin()); + let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU); + let to = o + v(tr * tt.cos(), tr * tt.sin()); + let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o)); + if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) { + continue; + } + kept += 1; + { + let sk = s.doc_mut().sketch_mut(id).expect("sketch"); + for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] { + sk.points[p].pos = at; + } + } + let before = side(&sketch(&s)); + drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]); + let sk = sketch(&s); + if side(&sk) != before { + flips += 1; + } + if sk.points[p3].pos.dist(to) > 1e-6 { + short += 1; + } + } + (flips, short) +} + +#[test] +fn a_dragged_two_link_arm_keeps_its_elbow_side() { + for links in [Links::Dimensioned, Links::EqualToFixed] { + let (flips, short) = arm_drags(links); + eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short"); + assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags"); + assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor"); + } +} + +/// Each step of a drag is seeded on the positions from before it, so even a drag made in one +/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are +/// dimensioned or held equal to fixed lines (the seed reads both). +#[test] +fn a_single_step_arm_drag_keeps_its_elbow_side() { + for links in [Links::Dimensioned, Links::EqualToFixed] { + let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links)); + eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short"); + assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags"); + assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor"); + } +} + +/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2) +/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged +/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n` +/// deterministic cases with the triangle at random turns and on either side. Returns the drags +/// that turned the triangle over. +fn hanging_triangle_flips(n: usize) -> usize { + use std::f64::consts::TAU; + let (a, b) = (30.0, 20.0); + let (lo, hi) = ((a - b) + 3.0, a + b - 3.0); + let o = v(200.0, 100.0); + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b})); + let hand = pt(&s, "l2.end"); + let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt())); + run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]})); + for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] { + run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value})); + } + let id = s.active_sketch.expect("sketch"); + let sk0 = sketch(&s); + let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}")); + let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end")); + let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end")); + let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum(); + let mut seed = 23u64; + let mut rnd = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let (mut kept, mut flips) = (0, 0); + while kept < n { + let th1 = rnd() * TAU; + let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 }; + let v2 = o + v(a * th1.cos(), a * th1.sin()); + let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin()); + let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU); + let to = o + v(tr * tt.cos(), tr * tt.sin()); + let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o)); + if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) { + continue; + } + kept += 1; + let turn = rnd() * TAU; + let u = solvecraft_geom::Vec2::from_angle(turn); + let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 }; + let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt()); + { + let sk = s.doc_mut().sketch_mut(id).expect("sketch"); + for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] { + sk.points[p].pos = at; + } + } + let before = side(&s); + let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]})); + assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); + assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end")); + if side(&s) != before { + flips += 1; + } + } + flips +} + +/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the +/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over, +/// however few steps the drag takes. +#[test] +fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() { + let flips: Vec<(usize, usize)> = + [1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect(); + eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}"); + assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}"); +} + +/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one +/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave +/// the elbow placed there; returns how many of the sketch's own solves that follow put it on +/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`. +fn pinned_elbow_flips(n: usize, move_point: bool) -> usize { + let o = v(200.0, 100.0); + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); + let start = s.doc.clone(); + let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum(); + let before = side(&s); + let mut flips = 0; + for i in 0..n { + s.doc = start.clone(); + let elbow = pt(&s, "l1.end"); + // Across the line, spread along it. + let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64); + let r = if move_point { + run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]})) + } else { + run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]})) + }; + assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); + if side(&s) != before { + flips += 1; + } + } + flips +} + +/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps +/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged +/// across the shoulder-hand line in one step, stays on its side. +#[test] +fn a_failed_drag_step_keeps_the_last_solved_orientation() { + for move_point in [false, true] { + let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point)); + eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips"); + assert_eq!(flips, 0, "move_point {move_point}"); + } +} + +/// After a failed step the drag follows as far as the geometry can, and `reached` says how far: +/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about +/// two thirds of the way. +#[test] +fn a_drag_follows_up_to_where_its_step_fails() { + let o = v(200.0, 100.0); + let mut s = new_sketch(); + // Elbow bent so the hand is 49 from the shoulder, along +x. + let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0); + let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt()); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); + assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1); + let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]})); + let reached = r["reached"].as_f64().unwrap_or(f64::NAN); + eprintln!("arm hand past its reach in one step: {r}"); + assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}"); + assert_eq!(r["sketch"]["solved"], json!(true), "{r}"); +} + +/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is +/// restored when the capped code panics. +#[test] +fn the_step_cap_leaves_the_plan_and_is_restored() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20})); + let uncapped = super::drag::step_cap::get(); + let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]); + assert!(plan > 1); + assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan); + assert_eq!(super::drag::step_cap::get(), uncapped); + assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err()); + assert_eq!(super::drag::step_cap::get(), uncapped); +} + +/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360). +fn sweep_deg(s: &Session, arc: &str) -> f64 { + let sk = sketch(s); + let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") }; + let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos); + ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees() +} + +/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°. +fn free_arc(sweep: f64) -> Session { + let mut s = new_sketch(); + run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]})); + s +} + +fn on_arc(deg: f64) -> [f64; 2] { + let t = deg.to_radians(); + [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()] +} + +/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or +/// 330° → 360° → 30°): the arc stops short, in one call or in many. +#[test] +fn a_dragged_arc_end_never_wraps_through_its_start() { + // One call, clockwise through the start: 90° → -60°. + let mut s = free_arc(90.0); + drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0)); + let sw = sweep_deg(&s, "a1"); + eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°"); + assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°"); + // One call, counter-clockwise past a full turn: 330° → 390°. + let mut s = free_arc(330.0); + drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0)); + let sw = sweep_deg(&s, "a1"); + eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°"); + assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°"); + // 30° calls, clockwise: 90 → -60. + let mut s = free_arc(90.0); + let mut last = 90.0; + let mut log = Vec::new(); + for deg in [60.0, 30.0, 0.0, -30.0, -60.0] { + drag(&mut s, "a1.end", on_arc(last), on_arc(deg)); + last = deg; + log.push(sweep_deg(&s, "a1")); + } + eprintln!("free arc, CW 30° calls: sweeps {log:.1?}"); + assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}"); + // 30° calls, counter-clockwise: 90 → 450. + let mut s = free_arc(90.0); + let mut last = 90.0; + let mut log = Vec::new(); + for deg in (4..=15).map(|i| (i * 30) as f64) { + drag(&mut s, "a1.end", on_arc(last), on_arc(deg)); + last = deg; + log.push(sweep_deg(&s, "a1")); + } + eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}"); + assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}"); + assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}"); +} + +/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner +/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns +/// over near 14° (the tangent length passes 40) and comes back on the way up. +#[test] +fn a_dragged_fillet_stays_in_its_corner() { + for (leg, strict) in [(80.0, true), (40.0, false)] { + let mut s = new_sketch(); + let o = v(100.0, 100.0); + run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"})); + let sk0 = sketch(&s); + let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc"); + let arc_id = sk0.curves[arc].id.clone(); + let inside = |s: &Session| { + let sk = sketch(s); + let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum(); + let c = sk.center(arc).expect("centre"); + let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end")); + let r = sk.radius(arc).unwrap_or(0.0); + side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6 + }; + assert!(inside(&s)); + let mut path: Vec = (45..=87).map(|i| (i * 2) as f64).collect(); + path.extend((7..=87).rev().map(|i| (i * 2) as f64)); + path.extend((8..=45).map(|i| (i * 2) as f64)); + let corner = o; + let at = |deg: f64| { + let t = deg.to_radians(); + [corner.x + leg * t.cos(), corner.y + leg * t.sin()] + }; + let mut bad = Vec::new(); + let mut last = at(90.0); + for deg in path { + let to = at(deg); + drag(&mut s, "l2.end", last, to); + last = to; + let sw = sweep_deg(&s, &arc_id); + if !(inside(&s) && sw < 180.0) { + bad.push(deg); + } + } + if strict { + assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}"); + } + assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})"); + } +} + +/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing. +#[test] +fn hostile_drags_change_nothing() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]})); + let l0 = snap(&s, "l1"); + for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] { + for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] { + for e in ["l1", "l1.end"] { + let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()})); + assert!(r.is_err(), "{e} {from} {to}: {r:?}"); + assert!(same(&snap(&s, "l1"), &l0)); + } + } + assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err()); + assert!(same(&snap(&s, "l1"), &l0)); + } + drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]); + drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]); + run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]})); + assert!(same(&snap(&s, "l1"), &l0)); + // A huge (but finite, in-range) jump on a constrained group is capped and never panics. + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64); + drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]); + let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end")); + assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}"); +} + +/// The solves a drag of `entity` from `from` to `to` would take. +fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize { + let sk = sketch(s); + let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}")); + super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1])) +} + +/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one +/// held solve, as it was before; only a long one is split. +#[test] +fn a_short_drag_is_a_single_solve() { + let mut s = new_sketch(); + // Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin). + for i in 0..10 { + let x = 100.0 + 30.0 * i as f64; + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]})); + let c = curve_ids(&r); + run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20})); + run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10})); + } + assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1); + assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1); + drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]); + assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9); + // A long move (more than an eighth of the 10 mm side) is split into steps. + assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1); + drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]); + assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9); +} + +/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that +/// only shares a fixed point with the dragged geometry does not shrink its steps. +#[test] +fn the_drag_step_ignores_geometry_beyond_a_fixed_point() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20})); + let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]); + run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01})); + assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before); + assert_eq!(before, 1); + // A free radius couples like a point: a line tangent to a circle whose centre is fixed + // steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line. + let mut s = new_sketch(); + run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]})); + run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false})); + run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"})); + assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6); +} + +/// The step counts every size the constraints hold, offsets along an axis too (the solver holds +/// their size on either side), except a length between two points the drag holds: a dimensioned +/// line slid by its middle keeps its length whatever the step. +#[test] +fn the_drag_step_counts_every_held_size_but_the_dragged_length() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5})); + let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]); + eprintln!("0.5 mm line slid 5 mm: {steps} steps"); + assert_eq!(steps, 1); + // B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays + // right of A instead of jumping to the other side. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]})); + run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false})); + run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05})); + assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. }))); + let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]); + drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]); + let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start")); + eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}"); + assert!(steps > 1); + assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}"); +} + +/// A circle shares only its centre with a dragged line's end (coincident): the solve couples +/// its centre, never its radius, so a tiny circle does not shrink the step. +#[test] +fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20})); + run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01})); + assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. }))); + let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]); + eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps"); + assert_eq!(steps, 1); +} + +/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand +/// through the hole round its shoulder) still lands on a cursor it can reach. +#[test] +fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() { + let mut s = new_sketch(); + let o = v(200.0, 100.0); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); + // From (30, 20) to (-30, -20) about the shoulder: the path passes through it. + let to = [o.x - 30.0, o.y - 20.0]; + let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to); + let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to})); + eprintln!("arm hand across the hole: {steps} steps, {r}"); + assert!(steps > 1); + assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end")); + assert_eq!(r["reached"], json!(1.0)); +} + +/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to +/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side. +#[test] +fn a_drag_past_an_unreachable_stretch_keeps_stepping() { + let mut s = new_sketch(); + let o = v(200.0, 100.0); + let elbow = o + v(19.702247520273062, 22.62347105662377); + let hand = o + v(39.654744430607906, 21.245840103473398); + let to = o + v(-28.82686238241851, -31.24580379872583); + run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20})); + // A triangle with sides 3.9, 2 and 2 on the hand. + let hand = pt(&s, "l2.end"); + let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt())); + run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]})); + for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] { + run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value})); + } + let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum(); + let before = side(&s); + let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]})); + eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s)); + assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end")); + assert_eq!(side(&s), before, "the triangle turned over"); + assert_eq!(r["reached"], json!(1.0)); +} + +/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at +/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged. +#[test] +fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() { + // (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad + // of the limit). + for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] { + let mut s = free_arc(sweep); + // A dimensioned line hangs off the arc's end. + let end = pt(&s, "a1.end"); + run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"})); + run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()})); + let sw0 = sweep_deg(&s, "a1"); + // The arc's own end. + let t = (end - v(50.0, 50.0)).angle() + nudge; + let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]; + drag(&mut s, "a1.end", [end.x, end.y], to); + assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move"); + // The joined line's far end, which pulls the arc's end along (inwards). + let far = pt(&s, "lc.end"); + let target = v(far.x, far.y + 1e-5 * nudge.signum()); + drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]); + assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move"); + assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1")); + } +} + #[test] fn fully_constrained_feedback_is_per_entity() { let mut s = new_sketch(); @@ -669,3 +1247,84 @@ fn pipe_and_sweep_follow_3d_sketch_curves() { assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}"); assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}"); } + +/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster +/// decomposition places: pinned at F by two of its corners and held at G by the third. Every +/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on. +fn hung_arm() -> Session { + let mut s = new_sketch(); + let o = v(200.0, 100.0); + let at = |x: f64, y: f64| json!([o.x + x, o.y + y]); + run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]})); + run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]})); + run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]})); + run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]})); + run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]})); + for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] { + run(&mut s, "sketch.dimension", json!({"entities": [id]})); + } + run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]})); + s +} + +/// The elbow's side of the shoulder–hand line. +fn hung_elbow_side(s: &Session) -> f64 { + let b = pt(s, "ab.end"); + (pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum() +} + +/// The hand of `hung_arm` swung by `angle` about the shoulder. +fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 { + let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end")); + let r = h - b; + let (c, si) = (angle.cos(), angle.sin()); + b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale +} + +/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short +/// steps (continuation keeps the elbow the seed no longer places). +#[test] +fn a_drag_out_of_decomposition_budget_takes_short_steps() { + let start = hung_arm(); + let before = hung_elbow_side(&start); + let h = pt(&start, "eh.end"); + let mut flips = 0; + for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] { + let mut s = hung_arm(); + let to = hung_hand_target(&s, k * 0.7, 1.0); + let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]); + let r = super::drag::last_drag::with_frame_work(0, || { + run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true})) + }); + let d = super::drag::last_drag::get(); + assert!(d.cut, "{d:?}"); + assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps"); + assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}"); + if hung_elbow_side(&s) != before { + flips += 1; + } + } + assert_eq!(flips, 0); +} + +/// All the held solves of one live frame, halvings included, share one decomposition budget: +/// together they do at most a frame's work, and one solve's more. +#[test] +fn a_live_drag_shares_one_decomposition_budget() { + let mut s = hung_arm(); + let h = pt(&s, "eh.end"); + let near = hung_hand_target(&s, 0.01, 1.0); + run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true})); + let one = super::drag::last_drag::get(); + assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}"); + // Swung round and out past the arm's reach: many steps, then halvings. + let mut s = hung_arm(); + let far = hung_hand_target(&s, 2.0, 1.5); + let budget = 3 * one.work; + super::drag::last_drag::with_frame_work(budget, || { + run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true})) + }); + let d = super::drag::last_drag::get(); + assert!(d.count > 6, "{d:?}"); + assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2befb07..93d05ed5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -217,6 +217,19 @@ pub struct Session { /// commands are undone however long the undo history is. pub(crate) checkpoints: Vec, next_checkpoint: u64, + /// Full model evaluations (`refresh`), for tests. + #[cfg(test)] + pub(crate) evaluations: usize, +} + +/// A sketch changed by one frame of an interactive gesture (`live: true`). +#[derive(Clone, Debug)] +pub struct LiveEdit { + pub sketch: u64, + /// What the frame's solve found: a full solve's report, or, when the sketch was solved + /// without a rank analysis, its last report's degrees of freedom and determined flags (a + /// drag does not change them; the evaluation at the gesture's end brings them up to date). + pub report: solvecraft_sketch::SolveReport, } /// The design, active sketch, pending positions and undo history at an `edit.checkpoint`. @@ -302,6 +315,8 @@ impl Session { checkpoints: Vec::new(), next_checkpoint: 1, visibility: Visibility::default(), + #[cfg(test)] + evaluations: 0, } } @@ -360,6 +375,7 @@ impl Session { if let Some(inverse) = solvecraft_doc::mat_inverse(&first) { let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse); for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) { + let sketch = Arc::make_mut(sketch); let plane = sketch.plane; if let Some(mapped) = solvecraft_geom::Plane::new( solvecraft_doc::apply_point(&delta, plane.origin), @@ -394,6 +410,10 @@ impl Session { pub fn refresh(&mut self) { self.model.evaluate(&self.doc); + #[cfg(test)] + { + self.evaluations += 1; + } self.revision += 1; self.prune_occurrence_state(); // Joints follow the geometry their origins snap to. @@ -407,10 +427,65 @@ impl Session { } } + /// Show the sketch a live frame changed, leaving the features after it as they were last + /// evaluated (marked stale); a full `refresh` when the model cannot show it that way. + fn refresh_live(&mut self, edit: LiveEdit) { + let sketch = self.doc.sketch(edit.sketch).ok().cloned(); + if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) { + self.revision += 1; + } else { + self.refresh(); + } + } + + /// The sketches whose last evaluation solved, each with its own parameter-driven values + /// (taken before a value edit, for [`Session::settle_value_edit`]). + pub(crate) fn solved_sketches(&self) -> SketchValues { + self.model + .results + .iter() + .filter_map(|r| r.state.sketch(r.id).filter(|k| k.report.keeps_orientation()).map(|k| (r.id, own_values(&k.sketch)))) + .collect() + } + + /// After the document's values changed (parameters, a configuration, the units): evaluate, + /// and refuse the edit when a sketch that solved before no longer does, or turns over, + /// because its own parameter-driven values changed (the command fails, so `execute` + /// restores the document's values and geometry). A sketch broken by a change upstream + /// (a projected or linked edge moved) is not the edit's to refuse: it carries a warning, + /// as before. Otherwise store each sketch whose values changed as solved, so the next edit + /// continues from it (see `Model::adopt_solved_sketches`). + pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> { + self.refresh(); + for r in &self.model.results { + let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue }; + if !k.report.keeps_orientation() && own_values(&k.sketch) != *before { + let why = k.report.failure(|f| format!("its constraints would conflict ({f})")); + return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name))); + } + } + // Checked first: storing makes the document unique (a copy when the undo step shares it). + if self.model.edited_sketches(&self.doc).next().is_some() { + self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc)); + } + Ok(()) + } + /// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step /// when they change the document; a failing or panicking command leaves it unchanged. + /// + /// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of + /// an interactive gesture: the model shows its sketch's new solution and keeps every later + /// feature's last result (stale) instead of evaluating them. Any other command that reads + /// the model ([`CommandSpec::reads_model`]) first evaluates a stale model in full, so the + /// gesture's end (`sketch.drag_end`, or whatever runs next after an abandoned gesture) brings + /// the model up to date before anything reads it. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true)); + if live.is_none() && spec.reads_model && self.model.is_stale() { + self.refresh(); + } (spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; let before = Snapshot { label: spec.label.to_string(), @@ -425,7 +500,10 @@ impl Session { let (_nested, outermost) = frames::Nested::enter(spec.id); let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None }; let params = mapped.as_ref().unwrap_or(params); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live { + Some(run) => run(self, params), + None => (spec.run)(self, params).map(|v| (v, None)), + })); let r = match r { Ok(r) => r, Err(p) => { @@ -435,7 +513,7 @@ impl Session { }; let changed = !Arc::ptr_eq(&self.doc, &before.doc); match r { - Ok(v) => { + Ok((v, live_edit)) => { let v = if changed { self.with_created(spec.id, params, &before, v) } else { v }; if changed { if spec.undoable { @@ -445,7 +523,10 @@ impl Session { } self.redo.clear(); } - self.refresh(); + match live_edit { + Some(edit) => self.refresh_live(edit), + None => self.refresh(), + } } Ok(v) } @@ -548,6 +629,8 @@ impl Session { checkpoints: Vec::new(), next_checkpoint: 1, visibility: self.visibility.clone(), + #[cfg(test)] + evaluations: 0, } } @@ -686,3 +769,12 @@ mod tests; mod thread_tests; #[cfg(test)] mod world_placement_cache_tests; + +/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]). +pub(crate) type SketchValues = std::collections::BTreeMap>; + +/// The values of a sketch's driving dimensions that come from parameters: what a value edit +/// changes in it (as opposed to geometry it projects or links from upstream). +fn own_values(sk: &solvecraft_sketch::Sketch) -> std::collections::BTreeMap { + sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect() +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e93f5eab..329a1a77 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1677,8 +1677,10 @@ fn cached_evaluation_matches_from_scratch() { let before = hits(&s); run(&mut s, id, p.clone()); same(&s, &format!("step {step}: {id} {p}")); - if step == 1 { - assert!(hits(&s) > before, "setting the width back reuses the earlier results"); + // Setting the width back solves the sketch again from its current state; the + // features after it see the same geometry and reuse their results. So does undo. + if step == 1 || step == 3 { + assert!(hits(&s) > before, "step {step} reuses the earlier results"); } } } diff --git a/crates/sketch/examples/solve_bench.rs b/crates/sketch/examples/solve_bench.rs new file mode 100644 index 00000000..89cabd85 --- /dev/null +++ b/crates/sketch/examples/solve_bench.rs @@ -0,0 +1,108 @@ +//! Solve times of an n×n grid (origin corner fixed, every row and column edge horizontal or +//! vertical with a distance), started at the solution and ±1 mm off it, and of one held drag +//! frame on a strip of rectangles. Run with +//! `cargo run --release -p solvecraft-sketch --example solve_bench`; results go in docs/perf.md. + +use std::time::Instant; + +use solvecraft_geom::Vec2; +use solvecraft_sketch::{Analysis, Budget, ConstraintKind, Hold, Sketch, SolveReport, solve, solve_with}; + +/// A small deterministic generator for the start offsets. +struct Lcg(u64); + +impl Lcg { + /// In [-1, 1). + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 + } +} + +fn grid(n: usize, jitter: f64) -> Result> { + let mut sk = Sketch::new(); + let mut rnd = Lcg(12345); + let mut id = vec![vec![0; n]; n]; + for (r, row) in id.iter_mut().enumerate() { + for (c, slot) in row.iter_mut().enumerate() { + let j = if r == 0 && c == 0 { Vec2::ZERO } else { Vec2::new(rnd.next(), rnd.next()) * jitter }; + *slot = sk.add_point(Vec2::new(c as f64 * 10.0, r as f64 * 10.0) + j, None)?; + } + } + sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None)?; + for r in 0..n { + for c in 0..n { + for (dr, dc, horizontal) in [(0, 1, true), (1, 0, false)] { + let (Some(&p), Some(&q)) = (id.get(r).and_then(|x| x.get(c)), id.get(r + dr).and_then(|x| x.get(c + dc))) else { continue }; + let l = sk.add_line_pts(p, q, None)?; + sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None)?; + sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None)?; + } + } + } + Ok(sk) +} + +/// `n` rectangles side by side sharing their vertical edges: horizontal and vertical sides, every +/// width dimensioned, the first height too. Returns the sketch and its first corner. +fn strip(n: usize) -> Result<(Sketch, usize), Box> { + let mut sk = Sketch::new(); + let bottom: Vec = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 10.0), None)).collect::>()?; + let top: Vec = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 25.0), None)).collect::>()?; + for i in 0..=n { + let l = sk.add_line_pts(bottom[i], top[i], None)?; + sk.add_constraint(ConstraintKind::Vertical { l }, None)?; + if i == 0 { + sk.add_constraint(ConstraintKind::Length { l, value: 15.0 }, None)?; + } + } + for i in 0..n { + let b = sk.add_line_pts(bottom[i], bottom[i + 1], None)?; + let t = sk.add_line_pts(top[i], top[i + 1], None)?; + sk.add_constraint(ConstraintKind::Horizontal { l: b }, None)?; + sk.add_constraint(ConstraintKind::Horizontal { l: t }, None)?; + sk.add_constraint(ConstraintKind::Length { l: b, value: 20.0 }, None)?; + } + Ok((sk, bottom[0])) +} + +/// Best of `reps` solves of a copy of `sk` (ms) and the last report. +fn time(sk: &Sketch, reps: usize, f: impl Fn(&mut Sketch) -> SolveReport) -> (f64, SolveReport) { + let mut best = f64::MAX; + let mut rep = None; + for _ in 0..reps { + let mut k = sk.clone(); + let t = Instant::now(); + let r = f(&mut k); + best = best.min(t.elapsed().as_secs_f64() * 1e3); + rep = Some(r); + } + (best, rep.unwrap_or_else(|| solve(&mut sk.clone()))) +} + +fn main() -> Result<(), Box> { + println!("| grid | points | start | ms | status | dof | iterations |"); + println!("|---|---|---|---|---|---|---|"); + for n in [10usize, 15, 20] { + for jitter in [0.0, 1.0] { + let sk = grid(n, jitter)?; + let (ms, rep) = time(&sk, 5, solve); + let start = if jitter == 0.0 { "at solution" } else { "±1 mm" }; + println!("| {n}×{n} | {} | {start} | {ms:.2} | {:?} | {} | {} |", n * n, rep.status, rep.dof, rep.iterations); + } + } + println!(); + println!("| strip | points | held drag frame ms | full solve ms |"); + println!("|---|---|---|---|"); + for n in [30usize, 100] { + let (mut sk, corner) = strip(n)?; + if let Some(p) = sk.points.get_mut(corner) { + p.pos -= Vec2::new(0.5, 0.0); + } + let hold = Hold { points: vec![corner], radii: vec![] }; + let (frame, rep) = time(&sk, 5, |k| solve_with(k, &hold, None, Analysis::StatusOnly, Some(&mut Budget::frame()))); + let (full, _) = time(&sk, 5, solve); + println!("| {n} rectangles | {} | {frame:.2} ({:?}) | {full:.2} |", sk.points.len(), rep.status); + } + Ok(()) +} diff --git a/crates/sketch/src/construct.rs b/crates/sketch/src/construct.rs new file mode 100644 index 00000000..72dfce10 --- /dev/null +++ b/crates/sketch/src/construct.rs @@ -0,0 +1,352 @@ +//! Constructive seed for the solver. +//! +//! Before Levenberg–Marquardt runs, every point the constraints pin down relative to the given +//! points is placed exactly (Owen 1991, §3 and §5; Fudos and Hoffmann 1997), in two +//! interleaved steps repeated until neither places anything: +//! +//! - **propagation**: a point with two loci anchored on already placed points goes to their +//! intersection; +//! - **decomposition** ([`crate::decompose`]): points no point-by-point order reaches but that +//! are rigid relative to the placed ones (Owen's articulation-pair reduction) are placed by +//! cluster merging, with the placed points as the ground. +//! +//! Then a rigid cluster that is free or pinned at a single placed point (nothing decides its +//! pose, and LM alone may land it mirrored) is placed by its reference pose +//! ([`Decomposition::floats`]). That is a guess at free motion ([`Placement::Float`]): the +//! solver starts from it, nothing is built on it. +//! +//! Of two mirror-image intersections, the one keeping the orientation the points had before +//! the solve is kept, so a dimension edit keeps each triangle's orientation, each arc on its +//! side of its tangent lines and each line's direction instead of letting the iteration fall +//! into a mirrored solution. +//! +//! The seed gives the iteration its start. Constructed values the constraints pin down one +//! after another, and whose constraints hold there, stay constants while LM solves the rest +//! (`solver::pinned`); every constraint is still verified, and the rank analysis of the solved +//! Jacobian still decides the degrees of freedom. A point with only one locus on placed points keeps its own position (projecting +//! it onto that locus would freeze a point that still has a degree of freedom). What neither +//! step places (triconnected remainders, under-constrained freedom, constraints read by +//! neither) is left to LM. +//! +//! What the constraints say about a point, and where two of them put it, is read by +//! [`crate::loci`] (the same reading drives the decomposition): coincident points; circles +//! (distances, line lengths, arc radii, points on circles of known radius, tangent circles); +//! rows and columns (horizontal and vertical lines and points, and offsets from them on the +//! side the point was on); lines through placed points along a known direction (a point on a +//! line, a line at a known angle, parallel or perpendicular to a placed line); lines at a +//! distance from a placed line (a point at a distance, a tangent circle's centre), on the side +//! the point was on; the radius to a touch point, normal to the line; midpoints and mirrored +//! points. A circle's radius set by its dimensions is seeded too. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use solvecraft_geom::Vec2; + +use crate::decompose::{Budget, Decomposition, enqueue}; +use crate::loci::{MAX_LOCI, Reading, Rel, View, place, radii, read}; +use crate::model::{Sketch, in_range}; + +/// Rounds of decomposition then propagation per seed. Each round after the first must follow a +/// propagation that placed something, so on real sketches the loop ends after a few; this +/// bounds a hostile chain that alternates. +const MAX_ROUNDS: usize = 16; + +/// Start positions for the solver: `pos[i]` is the position of point `i` when it is given or +/// constructed, `placed_by[i]` how it was placed; `radius[k]` the radius of circle or arc `k` +/// when its dimensions set it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Seed { + pub pos: Vec>, + pub placed_by: Vec>, + pub radius: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Placement { + /// Fixed, fixed by a constraint, a point of a fixed curve, or held for this solve. + Given, + /// At the intersection of two loci anchored on the placed points `a` ≤ `b` (the same point + /// when both loci come from it, or for a coincident point). `transversal` when the loci + /// cross (the point is pinned down well); false when they touch. + TwoLoci { a: usize, b: usize, transversal: bool }, + /// By cluster decomposition: rigid relative to the placed points, built from crossing + /// intersections only. + Cluster, + /// A point of a rigid cluster whose pose nothing decides (free, or pinned at a single + /// placed point), placed by the cluster's reference pose: a guess at free motion, so the + /// solver starts from it but nothing is built on it. + Float, +} + +impl Placement { + /// The solver starts from it. A touching placement is recorded but the solver starts the + /// point where it is. + pub(crate) fn anchors(self) -> bool { + !matches!(self, Placement::TwoLoci { transversal: false, .. }) + } + + /// Later points may be built on it, and the solver may keep it (`solver::pinned`): it is + /// pinned down well and not a guess. + pub(crate) fn builds(self) -> bool { + self.anchors() && self != Placement::Float + } +} + +/// Place the points the constraints pin down; `given[i]` marks the points that are constants +/// for this solve. Branches are chosen to match the orientation of `reference` (positions +/// before an interactive move) where it covers a point, of the sketch's current positions +/// otherwise; a reference longer than the point list is ignored. `on_curve` holds the sketch's +/// point-on-curve pairs ([`crate::solver::points_on_curves`]); the decomposition works within +/// what is left of `budget`, and charges it. +pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed { + let n = sk.points.len(); + let reference = reference.filter(|r| r.len() <= n); + let before = |i: usize| match reference.and_then(|r| r.get(i)) { + Some(p) => Some(*p), + None => sk.points.get(i).map(|p| p.pos), + }; + let is_given = |i: usize| given.get(i).copied().unwrap_or(false); + let pos: Vec> = sk.points.iter().enumerate().map(|(i, p)| (is_given(i) && in_range(p.pos)).then_some(p.pos)).collect(); + let placed_by = pos.iter().map(|p| p.map(|_| Placement::Given)).collect(); + let mut out = Seed { pos, placed_by, radius: radii(sk) }; + let reading = read(sk, &before, on_curve, &out.radius); + let mut prop = Propagation { + reading: &reading, + free: (0..n).map(|i| !is_given(i)).collect(), + anchors: out.pos.clone(), + placed_near: vec![0; n], + queued: vec![false; n], + queue: VecDeque::new(), + }; + for i in 0..n { + if prop.anchors.get(i).is_some_and(Option::is_some) { + prop.anchored(i); + } + } + // Every free point is examined once, and again when a neighbour placed later can complete + // a pair of loci (see `Propagation::anchored`); there is nothing to examine when no free + // point is related to a given one. + if prop.next_to_free() { + for i in (0..n).filter(|i| !is_given(*i)) { + enqueue(&mut prop.queued, &mut prop.queue, i); + } + } + let mut steps = n.saturating_add(reading.tie_count()).saturating_mul(2 * (MAX_LOCI + 1)); + prop.run(&mut out, &mut steps); + // Where propagation stalls, the decomposition places what is rigid relative to the + // anchors; propagation goes on from its placements, and the graph takes in what + // propagation placed. + if !switch::decompose_on() { + return out; + } + // A drag step solves against its reference and moves only held points; any other solve + // applies edits. + let edits = reference.is_none(); + let mut clusters = Decomposition::new(&reading, &prop.anchors, edits, budget.left()); + if !clusters.built() { + return out; + } + for _ in 0..MAX_ROUNDS { + let candidates: Vec<(usize, Vec2)> = (0..n).filter(|i| prop.is_free(*i)).filter_map(|i| Some((i, clusters.pos(i)?))).collect(); + let accepted = prop.consistent(&candidates); + if accepted.is_empty() { + break; + } + for (i, at) in accepted { + prop.place(&mut out, i, at, Placement::Cluster); + } + let anchored = prop.run(&mut out, &mut steps); + if anchored.is_empty() || clusters.exhausted() { + break; + } + clusters.know(&anchored); + } + // What the constraints decide is placed. A rigid cluster they leave free to move goes where + // its reference pose puts it, if that holds every relation to the anchors and no point of + // it is placed already: a guess, so it anchors nothing and the next is checked against the + // anchors alone. + for float in clusters.floats(&|i| prop.anchors.get(i).copied().flatten()) { + let open = float.iter().all(|(i, _)| out.pos.get(*i).is_some_and(Option::is_none)); + if open && prop.consistent(&float).len() == float.len() { + for (i, at) in float { + prop.place(&mut out, i, at, Placement::Float); + } + } + } + let (work, cut) = clusters.work(); + budget.charge(work, cut); + out +} + +/// Point-by-point placement state, kept across the decomposition rounds. +struct Propagation<'a> { + reading: &'a Reading, + /// Not given: may be placed. + free: Vec, + /// Points later points may be built on (see [`Placement::anchors`]). + anchors: Vec>, + /// Anchoring neighbours (by a relation between two points) per point. + placed_near: Vec, + queued: Vec, + queue: VecDeque, +} + +impl Propagation<'_> { + fn is_free(&self, i: usize) -> bool { + self.free.get(i).copied().unwrap_or(false) && self.anchors.get(i).is_some_and(Option::is_none) + } + + /// Some free point is related to an anchor. + fn next_to_free(&self) -> bool { + let anchored = |o: usize| self.anchors.get(o).is_some_and(Option::is_some); + (0..self.free.len()).any(|i| self.is_free(i) && self.reading.neighbours(i).any(anchored)) + } + + /// The candidate positions of free points, in range, that hold every relation to the + /// anchors and to the other candidates, as a propagated point must. One that misses is not + /// placed, nor is any candidate related to it (the decomposition may have built it on the + /// miss). + fn consistent(&self, candidates: &[(usize, Vec2)]) -> Vec<(usize, Vec2)> { + let by_point: BTreeMap = candidates.iter().copied().filter(|(i, at)| self.is_free(*i) && in_range(*at)).collect(); + let at = |i: usize| self.anchors.get(i).copied().flatten().or(by_point.get(&i).copied()); + let view = View { at: &at, oriented: true }; + let mut dropped: BTreeSet = by_point.iter().filter(|(i, x)| !self.reading.holds(**i, **x, &view)).map(|(i, _)| *i).collect(); + let mut cascade: Vec = dropped.iter().copied().collect(); + while let Some(i) = cascade.pop() { + for o in self.reading.neighbours(i) { + if by_point.contains_key(&o) && dropped.insert(o) { + cascade.push(o); + } + } + } + by_point.into_iter().filter(|(i, _)| !dropped.contains(i)).collect() + } + + /// Record `p` at `at`; an anchoring placement also anchors it and requeues the neighbours it + /// may complete (true then). A given point is never placed. + fn place(&mut self, out: &mut Seed, p: usize, at: Vec2, how: Placement) -> bool { + if !self.is_free(p) { + return false; + } + if let (Some(slot), Some(by)) = (out.pos.get_mut(p), out.placed_by.get_mut(p)) { + *slot = Some(at); + *by = Some(how); + } + if how.builds() + && let Some(slot) = self.anchors.get_mut(p) + { + *slot = Some(at); + self.anchored(p); + return true; + } + false + } + + /// Count the new anchor `p` at each neighbour, and requeue a free neighbour it can complete a + /// pair of loci for (up to the MAX_LOCI-th anchor): a coincident neighbour, the second + /// anchor, the first of a point with ties (they may give its other locus), and the points + /// of the ties `p` may complete. + fn anchored(&mut self, p: usize) { + let Propagation { reading, free, anchors, placed_near, queued, queue } = self; + let open = |o: usize| free.get(o).copied().unwrap_or(false) && anchors.get(o).is_some_and(Option::is_none); + for e in reading.adj(p) { + let o = e.other(p); + let Some(count) = placed_near.get_mut(o) else { continue }; + *count += 1; + if *count <= MAX_LOCI && (matches!(e.rel, Rel::Same) || *count >= 2 || reading.tie_count_of(o) > 0) && open(o) { + enqueue(queued, queue, o); + } + } + for o in reading.woken(p) { + if open(o) { + enqueue(queued, queue, o); + } + } + } + + /// Place queued points until the queue or `steps` runs out; the points that became anchors. + fn run(&mut self, out: &mut Seed, steps: &mut usize) -> Vec<(usize, Vec2)> { + let mut anchored = Vec::new(); + while *steps > 0 + && let Some(p) = self.queue.pop_front() + { + *steps -= 1; + if let Some(q) = self.queued.get_mut(p) { + *q = false; + } + if !self.is_free(p) { + continue; + } + let anchors = &self.anchors; + let at = |i: usize| anchors.get(i).copied().flatten(); + let items = self.reading.items(p, &View { at: &at, oriented: true }); + let Some(fix) = place(&items, self.reading.before(p)) else { continue }; + let how = Placement::TwoLoci { a: fix.a, b: fix.b, transversal: fix.transversal }; + if self.place(out, p, fix.at, how) { + anchored.push((p, fix.at)); + } + } + anchored + } +} + +#[cfg(test)] +pub(crate) mod switch { + //! Test-only switches: the seed, or only its decomposition step, can be turned off, and + //! every solve in this crate's tests is repeated without the seed to check that the + //! outcome is the same. + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + static SEED: Cell = const { Cell::new(true) }; + static DECOMPOSE: Cell = const { Cell::new(true) }; + static DIFFERENTIAL: Cell = const { Cell::new(true) }; + } + + pub(crate) fn seed_on() -> bool { + SEED.with(Cell::get) + } + pub(crate) fn decompose_on() -> bool { + DECOMPOSE.with(Cell::get) + } + pub(crate) fn differential_on() -> bool { + DIFFERENTIAL.with(Cell::get) + } + + /// Restores a switch when dropped, also when the test panics. + struct Restore(&'static LocalKey>, bool); + + impl Drop for Restore { + fn drop(&mut self) { + self.0.with(|s| s.set(self.1)); + } + } + + fn off(key: &'static LocalKey>, f: impl FnOnce() -> T) -> T { + let _restore = Restore(key, key.with(|s| s.replace(false))); + f() + } + /// Run `f` with the seed off. + pub(crate) fn without_seed(f: impl FnOnce() -> T) -> T { + off(&SEED, f) + } + /// Run `f` with the seed's propagation only (no decomposition). + pub(crate) fn without_decomposition(f: impl FnOnce() -> T) -> T { + off(&DECOMPOSE, f) + } + /// Run `f` without the seed-on/seed-off comparison (for tests that measure the difference). + pub(crate) fn without_differential(f: impl FnOnce() -> T) -> T { + off(&DIFFERENTIAL, f) + } +} + +#[cfg(not(test))] +pub(crate) mod switch { + pub(crate) const fn seed_on() -> bool { + true + } + pub(crate) const fn decompose_on() -> bool { + true + } +} diff --git a/crates/sketch/src/construct_rounds_tests.rs b/crates/sketch/src/construct_rounds_tests.rs new file mode 100644 index 00000000..aa02e971 --- /dev/null +++ b/crates/sketch/src/construct_rounds_tests.rs @@ -0,0 +1,813 @@ +//! Harness for the constructive seed on lines, arcs and tangency: a filleted rectangle, a slot, +//! a tangent line–arc–line chain (also at axis-aligned line angles), a bolt circle, a mirrored +//! point, collinear lines, points at a distance from a line, equal lines and points on an arc +//! of unknown radius, under scaled dimension edits. Every arc must keep its side of its lines +//! and its sweep, every line its direction, every point its side of its lines. +//! Deterministic (fixed LCG seeds), no timing. + +use solvecraft_geom::Vec2; + +use crate::construct::{Placement, seed}; +use crate::decompose::Budget; +use crate::solver::points_on_curves; +use crate::tests::{Lcg, given, seed_of, seeded, v}; +use crate::*; + +/// What an edit must keep. +#[derive(Clone, Copy, Debug)] +enum Check { + /// `p` stays on its side of the line through `a` and `b`. + Side { p: usize, a: usize, b: usize }, + /// The line from `a` to `b` points within a quarter turn of `d` (where the edit turns it). + Toward { a: usize, b: usize, d: Vec2 }, + /// `p` stays ahead of or behind `a` along the line from `a` to `b`. + Along { p: usize, a: usize, b: usize }, + /// The counter-clockwise sweep of the arc `curve` stays in `lo..hi` (radians). + Sweep { curve: usize, lo: f64, hi: f64 }, + /// `p` ends at `at`. + At { p: usize, at: Vec2 }, +} + +/// A family member: the solved sketch, the dimension edits (constraint index, new value) and +/// what they must keep. +struct Case { + sk: Sketch, + edits: Vec<(usize, f64)>, + checks: Vec, +} + +fn sweep(sk: &Sketch, curve: usize) -> f64 { + let CurveKind::Arc { c, a, b } = sk.curves[curve].kind else { return f64::NAN }; + let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos); + ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU) +} + +/// Does `sk` keep every check (sides as in `before`)? +fn holds(sk: &Sketch, checks: &[Check], before: &Sketch) -> bool { + let p = |i: usize| sk.points[i].pos; + checks.iter().all(|c| match *c { + Check::Side { p: x, a, b } => { + let side = |s: &Sketch| (s.points[b].pos - s.points[a].pos).cross(s.points[x].pos - s.points[a].pos).signum(); + side(sk) == side(before) + } + Check::Toward { a, b, d } => (p(b) - p(a)).dot(d) > 0.0, + Check::Along { p: x, a, b } => { + let along = |s: &Sketch| (s.points[b].pos - s.points[a].pos).dot(s.points[x].pos - s.points[a].pos).signum(); + along(sk) == along(before) + } + Check::Sweep { curve, lo, hi } => (lo..hi).contains(&sweep(sk, curve)), + Check::At { p: x, at } => p(x).dist(at) < 1e-6, + }) +} + +/// The member's sketch with its edits applied. +fn edited(case: &Case) -> Sketch { + let mut sk = case.sk.clone(); + for (i, value) in &case.edits { + sk.constraints[*i].kind.set_value(*value); + } + sk +} + +/// Apply the edits and solve; (failed, a check broke). +fn run(case: &Case, on: bool) -> (bool, bool) { + let mut sk = edited(case); + let rep = seeded(on, || solve(&mut sk)); + if !rep.ok() { + return (true, false); + } + (false, !holds(&sk, &case.checks, &case.sk)) +} + +fn push(sk: &mut Sketch, kind: ConstraintKind) -> usize { + sk.add_constraint(kind, None).unwrap(); + sk.constraints.len() - 1 +} + +/// The four arcs' ends and centres of a rounded rectangle, CCW: bottom, right, top and left +/// lines between quarter arcs at the corners, every line tangent to both its arcs. The bottom +/// line's start is fixed; lengths of the bottom and left lines and the four radii are +/// dimensions, scaled by `lo..hi`. Only edits where the top and right lines keep a length are +/// kept. +fn filleted_rectangle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let o = v(rnd.range(-50.0, 50.0), rnd.range(-50.0, 50.0)); + let (w, h) = (rnd.range(60.0, 100.0), rnd.range(40.0, 70.0)); + let r: [f64; 4] = std::array::from_fn(|_| rnd.range(4.0, 12.0)); // BR, TR, TL, BL + let mut sk = Sketch::new(); + let at = |x: f64, y: f64| o + v(x, y); + let bottom = sk.add_line(at(r[3], 0.0), at(w - r[0], 0.0), None, None, None).unwrap(); + let right = sk.add_line(at(w, r[0]), at(w, h - r[1]), None, None, None).unwrap(); + let top = sk.add_line(at(w - r[1], h), at(r[2], h), None, None, None).unwrap(); + let left = sk.add_line(at(0.0, h - r[2]), at(0.0, r[3]), None, None, None).unwrap(); + let ends = |sk: &Sketch, l: usize| sk.curves[l].kind.ends().unwrap(); + let ((b0, b1), (r0, r1), (t0, t1), (l0, l1)) = (ends(&sk, bottom), ends(&sk, right), ends(&sk, top), ends(&sk, left)); + let corners = [ + (at(w - r[0], r[0]), b1, r0, bottom, right), + (at(w - r[1], h - r[1]), r1, t0, right, top), + (at(r[2], h - r[2]), t1, l0, top, left), + (at(r[3], r[3]), l1, b0, left, bottom), + ]; + let mut checks = Vec::new(); + let mut radius = Vec::new(); + for (k, (c, a, b, la, lb)) in corners.into_iter().enumerate() { + let (pa, pb) = (sk.points[a].pos, sk.points[b].pos); + let arc = sk.add_arc(c, pa, pb, [None, Some(a), Some(b)], None).unwrap(); + let CurveKind::Arc { c: centre, .. } = sk.curves[arc].kind else { panic!() }; + push(&mut sk, ConstraintKind::Tangent { a: la, b: arc }); + push(&mut sk, ConstraintKind::Tangent { a: lb, b: arc }); + radius.push((push(&mut sk, ConstraintKind::Radius { c: arc, value: r[k] }), r[k])); + for l in [la, lb] { + let (a, b) = ends(&sk, l); + checks.push(Check::Side { p: centre, a, b }); + } + checks.push(Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI }); + } + for l in [bottom, right, top, left] { + let (a, b) = ends(&sk, l); + checks.push(Check::Toward { a, b, d: sk.points[b].pos - sk.points[a].pos }); + } + push(&mut sk, ConstraintKind::Fix { p: b0 }); + push(&mut sk, ConstraintKind::Horizontal { l: bottom }); + push(&mut sk, ConstraintKind::Horizontal { l: top }); + push(&mut sk, ConstraintKind::Vertical { l: left }); + push(&mut sk, ConstraintKind::Vertical { l: right }); + let (lb, ll) = (w - r[3] - r[0], h - r[2] - r[3]); + let len_b = push(&mut sk, ConstraintKind::Length { l: bottom, value: lb }); + let len_l = push(&mut sk, ConstraintKind::Length { l: left, value: ll }); + let mut s = || rnd.range(lo, hi); + let r2: Vec = radius.iter().map(|(_, r)| r * s()).collect(); + let (lb2, ll2) = (lb * s(), ll * s()); + // Width and height after the edit; the top and right lines must keep a length. + let (w2, h2) = (lb2 + r2[3] + r2[0], ll2 + r2[2] + r2[3]); + if w2 - r2[1] - r2[2] < 2.0 || h2 - r2[0] - r2[1] < 2.0 { + return None; + } + let mut edits: Vec<(usize, f64)> = radius.iter().zip(&r2).map(|((i, _), r)| (*i, *r)).collect(); + edits.extend([(len_b, lb2), (len_l, ll2)]); + Some(Case { sk, edits, checks }) +} + +/// A slot at an angle: two semicircular caps of equal radius joined by two lines tangent to +/// both, the bottom line at a line angle and the top one parallel to it, cap A's centre fixed. +/// The angle turns by up to ±145°, the radius and the cap distance (centre distance, or the +/// overall length) scale by `lo..hi`. +fn slot(rnd: &mut Lcg, lo: f64, hi: f64, overall: bool) -> Option { + let th = rnd.range(-3.1, 3.1); + let (len, r) = (rnd.range(30.0, 60.0), rnd.range(5.0, 13.0)); + let (u, n) = (Vec2::from_angle(th), Vec2::from_angle(th).perp()); + let mut sk = Sketch::new(); + let ca = v(rnd.range(-40.0, 40.0), rnd.range(-40.0, 40.0)); + let cb = ca + u * len; + let bottom = sk.add_line(ca - n * r, cb - n * r, None, None, None).unwrap(); + let top = sk.add_line(cb + n * r, ca + n * r, None, None, None).unwrap(); + let (ba, bb) = sk.curves[bottom].kind.ends().unwrap(); + let (tb, ta) = sk.curves[top].kind.ends().unwrap(); + let arc_b = sk.add_arc(cb, cb - n * r, cb + n * r, [None, Some(bb), Some(tb)], None).unwrap(); + let arc_a = sk.add_arc(ca, ca + n * r, ca - n * r, [None, Some(ta), Some(ba)], None).unwrap(); + let centre = |sk: &Sketch, a: usize| match sk.curves[a].kind { + CurveKind::Arc { c, .. } => c, + _ => panic!(), + }; + let (pa, pb) = (centre(&sk, arc_a), centre(&sk, arc_b)); + push(&mut sk, ConstraintKind::Fix { p: pa }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: bottom, value: th }); + push(&mut sk, ConstraintKind::Parallel { a: top, b: bottom }); + for (l, a) in [(bottom, arc_a), (bottom, arc_b), (top, arc_a), (top, arc_b)] { + push(&mut sk, ConstraintKind::Tangent { a: l, b: a }); + } + let radius = push(&mut sk, ConstraintKind::Radius { c: arc_a, value: r }); + push(&mut sk, ConstraintKind::Equal { a: arc_a, b: arc_b }); + let length = if overall { + push(&mut sk, ConstraintKind::SlotOverallLength { a: arc_a, b: arc_b, value: len + 2.0 * r }) + } else { + push(&mut sk, ConstraintKind::Distance { p: pa, q: pb, value: len }) + }; + let (r2, len2) = (r * rnd.range(lo, hi), len * rnd.range(lo, hi)); + let th2 = th + rnd.range(-2.5, 2.5); + let half = std::f64::consts::FRAC_PI_2; + let checks = vec![ + Check::Side { p: pa, a: ba, b: bb }, + Check::Side { p: pb, a: ba, b: bb }, + Check::Side { p: pa, a: tb, b: ta }, + Check::Side { p: pb, a: tb, b: ta }, + Check::Sweep { curve: arc_a, lo: half, hi: 3.0 * half }, + Check::Sweep { curve: arc_b, lo: half, hi: 3.0 * half }, + Check::Toward { a: pa, b: pb, d: Vec2::from_angle(th2) }, + ]; + let edits = vec![(angle, th2), (radius, r2), (length, if overall { len2 + 2.0 * r2 } else { len2 })]; + Some(Case { sk, edits, checks }) +} + +/// Line – arc – line, tangent at shared ends: L1 from a fixed point at a line angle with a +/// length, the arc turning left or right by β with a radius, L2 at β from L1 with a length. +/// Lengths and radius scale by `lo..hi`, the line angle turns by up to ±115°, β scales within +/// 15°–165°. +fn tangent_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let alpha = rnd.range(-3.1, 3.1); + let alpha2 = alpha + rnd.range(-2.0, 2.0); + chain(rnd, lo, hi, alpha, alpha2) +} + +/// [`tangent_chain`] with L1 along an axis, turned to another axis (a quarter or half turn +/// either way). +fn axis_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let quarter = std::f64::consts::FRAC_PI_2; + let alpha = quarter * (rnd.next() * 4.0).floor() - std::f64::consts::PI; + let turn = [-2.0, -1.0, 1.0, 2.0][(rnd.next() * 4.0) as usize % 4]; + chain(rnd, lo, hi, alpha, alpha + quarter * turn) +} + +/// The chain of [`tangent_chain`] with L1 at `alpha`, edited to `alpha2`. +fn chain(rnd: &mut Lcg, lo: f64, hi: f64, alpha: f64, alpha2: f64) -> Option { + let (a, b, r) = (rnd.range(20.0, 40.0), rnd.range(20.0, 40.0), rnd.range(5.0, 15.0)); + let beta = rnd.range(30f64.to_radians(), 150f64.to_radians()); + let turn = rnd.sign(); + let p0 = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)); + let u1 = Vec2::from_angle(alpha); + let u2 = Vec2::from_angle(alpha + turn * beta); + let e1 = p0 + u1 * a; + let c = e1 + u1.perp() * (turn * r); + let e2 = c - u2.perp() * (turn * r); + let p3 = e2 + u2 * b; + let mut sk = Sketch::new(); + let l1 = sk.add_line(p0, e1, None, None, None).unwrap(); + let (i0, i1) = sk.curves[l1].kind.ends().unwrap(); + let l2 = sk.add_line(e2, p3, None, None, None).unwrap(); + let (i2, i3) = sk.curves[l2].kind.ends().unwrap(); + let arc = if turn > 0.0 { + sk.add_arc(c, e1, e2, [None, Some(i1), Some(i2)], None).unwrap() + } else { + sk.add_arc(c, e2, e1, [None, Some(i2), Some(i1)], None).unwrap() + }; + let CurveKind::Arc { c: ic, .. } = sk.curves[arc].kind else { panic!() }; + push(&mut sk, ConstraintKind::Fix { p: i0 }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: alpha }); + let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a }); + let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: turn * beta, flip: false }); + let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b }); + let radius = push(&mut sk, ConstraintKind::Radius { c: arc, value: r }); + push(&mut sk, ConstraintKind::Tangent { a: l1, b: arc }); + push(&mut sk, ConstraintKind::Tangent { a: l2, b: arc }); + let mut s = || rnd.range(lo, hi); + let beta2 = (beta * s()).clamp(15f64.to_radians(), 165f64.to_radians()); + let edits = vec![(len1, a * s()), (len2, b * s()), (radius, r * s()), (bend, turn * beta2), (angle, alpha2)]; + let checks = vec![ + Check::Side { p: ic, a: i0, b: i1 }, + Check::Side { p: ic, a: i2, b: i3 }, + Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI }, + Check::Toward { a: i0, b: i1, d: Vec2::from_angle(alpha2) }, + Check::Toward { a: i2, b: i3, d: Vec2::from_angle(alpha2 + turn * beta2) }, + ]; + Some(Case { sk, edits, checks }) +} + +/// Bolt circle: `k` points on a circle (diameter dimension, centre fixed), each on a line from +/// the centre; the first line at a line angle, each next at 360°/k from the one before. The +/// diameter scales by `lo..hi`, the first angle turns by up to ±170°; every point must end where +/// the dimensions put it. +fn bolt_circle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let k = 3 + (rnd.next() * 6.0) as usize; + let r = rnd.range(15.0, 40.0); + let phi = rnd.range(-3.1, 3.1); + let step = std::f64::consts::TAU / k as f64; + let mut sk = Sketch::new(); + let centre = sk.add_point(v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)), None).unwrap(); + let c = sk.points[centre].pos; + let circle = sk.add_circle(c, r, Some(centre), None).unwrap(); + push(&mut sk, ConstraintKind::Fix { p: centre }); + let diameter = push(&mut sk, ConstraintKind::Diameter { c: circle, value: 2.0 * r }); + let mut lines = Vec::new(); + let mut holes = Vec::new(); + for i in 0..k { + let h = sk.add_point(c + Vec2::from_angle(phi + i as f64 * step) * r, None).unwrap(); + lines.push(sk.add_line_pts(centre, h, None).unwrap()); + holes.push(h); + push(&mut sk, ConstraintKind::PointOnCurve { p: h, c: circle }); + } + let angle = push(&mut sk, ConstraintKind::LineAngle { l: lines[0], value: phi }); + for w in lines.windows(2) { + push(&mut sk, ConstraintKind::Angle { a: w[0], b: w[1], value: step, flip: false }); + } + let (r2, phi2) = (r * rnd.range(lo, hi), phi + rnd.range(-3.0, 3.0)); + let checks = holes.iter().enumerate().map(|(i, h)| Check::At { p: *h, at: c + Vec2::from_angle(phi2 + i as f64 * step) * r2 }).collect(); + Some(Case { sk, edits: vec![(diameter, 2.0 * r2), (angle, phi2)], checks }) +} + +/// A point mirrored across a line: the line from a fixed point at a line angle with a length, +/// `p` at two distances from its ends (one side), `q` its mirror image. The angle turns by up +/// to ±115°, the dimensions scale by `lo..hi` (only edits where the triangle still exists). +fn mirrored(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let theta = rnd.range(-3.1, 3.1); + let len = rnd.range(30.0, 60.0); + let (u, a) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0))); + let (t, h) = (rnd.range(0.2, 0.8) * len, rnd.range(5.0, 20.0) * rnd.sign()); + let (p, q) = (a + u * t + u.perp() * h, a + u * t - u.perp() * h); + let mut sk = Sketch::new(); + let axis = sk.add_line(a, a + u * len, None, None, None).unwrap(); + let (ia, ib) = sk.curves[axis].kind.ends().unwrap(); + let (ip, iq) = (sk.add_point(p, None).unwrap(), sk.add_point(q, None).unwrap()); + push(&mut sk, ConstraintKind::Fix { p: ia }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: axis, value: theta }); + let length = push(&mut sk, ConstraintKind::Length { l: axis, value: len }); + let (d1, d2) = (p.dist(a), p.dist(a + u * len)); + let k1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 }); + let k2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ip, value: d2 }); + push(&mut sk, ConstraintKind::Symmetric { p: ip, q: iq, l: axis }); + let theta2 = theta + rnd.range(-2.0, 2.0); + let mut s = || rnd.range(lo, hi); + let (len2, e1, e2) = (len * s(), d1 * s(), d2 * s()); + if e1 + e2 < len2 * 1.02 || (e1 - e2).abs() > len2 * 0.98 { + return None; + } + let checks = + vec![Check::Side { p: ip, a: ia, b: ib }, Check::Side { p: iq, a: ia, b: ib }, Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) }]; + Some(Case { sk, edits: vec![(angle, theta2), (length, len2), (k1, e1), (k2, e2)], checks }) +} + +/// Two collinear lines: L1 from a fixed point at a line angle with a length, L2 collinear with +/// it, its start a distance past L1's end, with a length. The angle turns by up to ±115°, the +/// dimensions scale by `lo..hi`; both lines and the gap keep their direction. +fn collinear(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let theta = rnd.range(-3.1, 3.1); + let (a, g, b) = (rnd.range(20.0, 40.0), rnd.range(5.0, 30.0), rnd.range(20.0, 40.0)); + let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0))); + let mut sk = Sketch::new(); + let l1 = sk.add_line(o, o + u * a, None, None, None).unwrap(); + let l2 = sk.add_line(o + u * (a + g), o + u * (a + g + b), None, None, None).unwrap(); + let ((i0, i1), (i2, i3)) = (sk.curves[l1].kind.ends().unwrap(), sk.curves[l2].kind.ends().unwrap()); + push(&mut sk, ConstraintKind::Fix { p: i0 }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta }); + let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a }); + push(&mut sk, ConstraintKind::Collinear { a: l1, b: l2 }); + let gap = push(&mut sk, ConstraintKind::Distance { p: i1, q: i2, value: g }); + let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b }); + let theta2 = theta + rnd.range(-2.0, 2.0); + let u2 = Vec2::from_angle(theta2); + let mut s = || rnd.range(lo, hi); + let edits = vec![(angle, theta2), (len1, a * s()), (gap, g * s()), (len2, b * s())]; + let checks = vec![Check::Toward { a: i0, b: i1, d: u2 }, Check::Toward { a: i1, b: i2, d: u2 }, Check::Toward { a: i2, b: i3, d: u2 }]; + Some(Case { sk, edits, checks }) +} + +/// Points at a distance from a line: the line from a fixed point at a line angle with a +/// length; `p` at a distance from it (left) and from its start, `r` at half a linear diameter +/// from it (right) and at a distance from its end. The angle turns by up to ±115°, the +/// dimensions scale by `lo..hi` (only edits where both points still exist). +fn line_distance(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let theta = rnd.range(-3.1, 3.1); + let len = rnd.range(30.0, 60.0); + let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0))); + let n = u.perp(); + let (h1, h2) = (rnd.range(5.0, 20.0), rnd.range(5.0, 20.0)); + let p = o + u * rnd.range(5.0, 25.0) + n * h1; + let r = o + u * (len - rnd.range(5.0, 25.0)) - n * h2; + let mut sk = Sketch::new(); + let l = sk.add_line(o, o + u * len, None, None, None).unwrap(); + let (ia, ib) = sk.curves[l].kind.ends().unwrap(); + let (ip, ir) = (sk.add_point(p, None).unwrap(), sk.add_point(r, None).unwrap()); + push(&mut sk, ConstraintKind::Fix { p: ia }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l, value: theta }); + let length = push(&mut sk, ConstraintKind::Length { l, value: len }); + let kh1 = push(&mut sk, ConstraintKind::PointLineDistance { p: ip, l, value: h1 }); + let (d1, d2) = (p.dist(o), r.dist(o + u * len)); + let kd1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 }); + let kh2 = push(&mut sk, ConstraintKind::LinearDiameter { p: ir, l, value: 2.0 * h2 }); + let kd2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ir, value: d2 }); + let theta2 = theta + rnd.range(-2.0, 2.0); + let mut s = || rnd.range(lo, hi); + let (len2, g1, e1, g2, e2) = (len * s(), h1 * s(), d1 * s(), h2 * s(), d2 * s()); + if e1 < g1 * 1.1 || e2 < g2 * 1.1 { + return None; + } + let edits = vec![(angle, theta2), (length, len2), (kh1, g1), (kd1, e1), (kh2, 2.0 * g2), (kd2, e2)]; + let checks = vec![ + Check::Side { p: ip, a: ia, b: ib }, + Check::Side { p: ir, a: ia, b: ib }, + Check::Along { p: ip, a: ia, b: ib }, + Check::Along { p: ir, a: ib, b: ia }, + Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) }, + ]; + Some(Case { sk, edits, checks }) +} + +/// Equal lines: L1 from a fixed point at a line angle, its end at a distance (no length +/// dimension), L2 from L1's end at an angle β from it and equal to it. The angle turns by up +/// to ±115°, the distance scales by `lo..hi`, β within 15°–165° either way. +fn equal_lines(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let theta = rnd.range(-3.1, 3.1); + let a = rnd.range(20.0, 40.0); + let beta = rnd.range(30f64.to_radians(), 150f64.to_radians()) * rnd.sign(); + let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)); + let b = o + Vec2::from_angle(theta) * a; + let mut sk = Sketch::new(); + let l1 = sk.add_line(o, b, None, None, None).unwrap(); + let (i0, i1) = sk.curves[l1].kind.ends().unwrap(); + let l2 = sk.add_line(b, b + Vec2::from_angle(theta + beta) * a, Some(i1), None, None).unwrap(); + let (_, i2) = sk.curves[l2].kind.ends().unwrap(); + push(&mut sk, ConstraintKind::Fix { p: i0 }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta }); + let dist = push(&mut sk, ConstraintKind::Distance { p: i0, q: i1, value: a }); + let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: beta, flip: false }); + push(&mut sk, ConstraintKind::Equal { a: l1, b: l2 }); + let theta2 = theta + rnd.range(-2.0, 2.0); + let beta2 = beta.signum() * (beta.abs() * rnd.range(lo, hi)).clamp(15f64.to_radians(), 165f64.to_radians()); + let edits = vec![(angle, theta2), (dist, a * rnd.range(lo, hi)), (bend, beta2)]; + let checks = vec![ + Check::Toward { a: i0, b: i1, d: Vec2::from_angle(theta2) }, + Check::Toward { a: i1, b: i2, d: Vec2::from_angle(theta2 + beta2) }, + Check::Side { p: i2, a: i0, b: i1 }, + ]; + Some(Case { sk, edits, checks }) +} + +/// Points on an arc of unknown radius: the arc's centre fixed, its start on a line from the +/// centre at a line angle and a distance (no radius dimension), its end and a point on it on +/// lines from the centre at angles σ and τ from the first. The angle turns by up to ±115°, the +/// distance scales by `lo..hi`, σ within 20°–170° (τ keeps its share of σ). +fn on_arc(rnd: &mut Lcg, lo: f64, hi: f64) -> Option { + let phi = rnd.range(-3.1, 3.1); + let r = rnd.range(15.0, 40.0); + let sigma = rnd.range(40f64.to_radians(), 150f64.to_radians()); + let share = rnd.range(0.2, 0.8); + let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)); + let at = |a: f64| o + Vec2::from_angle(a) * r; + let mut sk = Sketch::new(); + let arc = sk.add_arc(o, at(phi), at(phi + sigma), [None, None, None], None).unwrap(); + let CurveKind::Arc { c, a: s0, b: e0 } = sk.curves[arc].kind else { panic!() }; + let p = sk.add_point(at(phi + share * sigma), None).unwrap(); + let (ls, le, lp) = (sk.add_line_pts(c, s0, None).unwrap(), sk.add_line_pts(c, e0, None).unwrap(), sk.add_line_pts(c, p, None).unwrap()); + push(&mut sk, ConstraintKind::Fix { p: c }); + let angle = push(&mut sk, ConstraintKind::LineAngle { l: ls, value: phi }); + let dist = push(&mut sk, ConstraintKind::Distance { p: c, q: s0, value: r }); + let ke = push(&mut sk, ConstraintKind::Angle { a: ls, b: le, value: sigma, flip: false }); + let kp = push(&mut sk, ConstraintKind::Angle { a: ls, b: lp, value: share * sigma, flip: false }); + push(&mut sk, ConstraintKind::PointOnCurve { p, c: arc }); + let phi2 = phi + rnd.range(-2.0, 2.0); + let sigma2 = (sigma * rnd.range(lo, hi)).clamp(20f64.to_radians(), 170f64.to_radians()); + let edits = vec![(angle, phi2), (dist, r * rnd.range(lo, hi)), (ke, sigma2), (kp, share * sigma2)]; + let checks = vec![ + Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI }, + Check::Toward { a: c, b: e0, d: Vec2::from_angle(phi2 + sigma2) }, + Check::Toward { a: c, b: p, d: Vec2::from_angle(phi2 + share * sigma2) }, + ]; + Some(Case { sk, edits, checks }) +} + +type Family = fn(&mut Lcg, f64, f64) -> Option; + +fn families() -> Vec<(&'static str, Family)> { + vec![ + ("filleted rectangle", filleted_rectangle), + ("slot (centre distance)", |r, lo, hi| slot(r, lo, hi, false)), + ("slot (overall length)", |r, lo, hi| slot(r, lo, hi, true)), + ("tangent chain", tangent_chain), + ("axis-aligned chain", axis_chain), + ("bolt circle", bolt_circle), + ("mirrored point", mirrored), + ("collinear lines", collinear), + ("distance from a line", line_distance), + ("equal lines", equal_lines), + ("points on an arc", on_arc), + ] +} + +const EDITS: usize = 200; + +/// `n` feasible members of `family` at scales `lo..hi`; fails the test when the family yields +/// fewer than one in fifty. +fn sample(rnd: &mut Lcg, family: Family, lo: f64, hi: f64, n: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..n.saturating_mul(50) { + if out.len() == n { + break; + } + out.extend(family(rnd, lo, hi)); + } + assert_eq!(out.len(), n, "the family yields too few feasible members at {lo}–{hi}×"); + out +} + +/// (failed, broke a check) over `EDITS` kept edits of `family` at scales `lo..hi`. +fn tally(family: Family, lo: f64, hi: f64, on: bool) -> (usize, usize) { + let mut rnd = Lcg(7); + let (mut failed, mut broke) = (0, 0); + for case in sample(&mut rnd, family, lo, hi, EDITS) { + let (f, b) = run(&case, on); + failed += f as usize; + broke += b as usize; + } + (failed, broke) +} + +fn table(on: bool) -> Vec<(&'static str, f64, f64, usize, usize)> { + let mut out = Vec::new(); + for (name, family) in families() { + for (lo, hi) in [(0.8, 1.25), (0.5, 2.0), (0.3, 3.0)] { + let (failed, broke) = tally(family, lo, hi, on); + eprintln!("seed {} {name:<24} {lo}–{hi}×: {failed} failed, {broke}/{EDITS} lost a side", if on { "on " } else { "off" }); + out.push((name, lo, hi, failed, broke)); + } + } + out +} + +#[test] +fn rounds_and_tangents_keep_their_sides_on_dimension_edits() { + let rows = table(true); + assert!(rows.iter().all(|r| r.3 == 0 && r.4 == 0), "{rows:?}"); +} + +/// The measurement the seed is judged against: without it, LM folds arcs and lines over. +#[test] +#[ignore = "measurement: run with --ignored --nocapture"] +fn rounds_and_tangents_without_the_seed() { + table(false); +} + +/// The solved members of every family are left exactly where they are. +#[test] +fn solved_members_stay_put() { + let mut rnd = Lcg(3); + for (name, family) in families() { + for case in sample(&mut rnd, family, 1.0, 1.0, 10) { + let mut sk = case.sk.clone(); + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 0, "{name}: {rep:?}"); + for (p, q) in sk.points.iter().zip(&case.sk.points) { + assert!(p.pos.dist(q.pos) < 1e-7, "{name}: {} moved {:?} → {:?}", p.id, q.pos, p.pos); + } + } + } +} + +/// The seed only moves the start: through the crate-wide seed-on/seed-off comparison (`solve` +/// in tests), the edits give the same status, degrees of freedom and determined flags. +#[test] +fn seed_differential_on_every_family() { + let mut rnd = Lcg(5); + for (_, family) in families() { + for case in sample(&mut rnd, family, 0.5, 2.0, 15) { + let mut sk = edited(&case); + solve(&mut sk); + } + } +} + +/// Every point of every family is placed by the seed (not left to LM), so edits solve in +/// few iterations. +#[test] +fn every_point_of_every_family_is_constructed() { + let mut rnd = Lcg(9); + for (name, family) in families() { + for case in sample(&mut rnd, family, 0.5, 2.0, 10) { + let sk = edited(&case); + let s = seed_of(&sk); + let used: Vec = sk.curves.iter().flat_map(|c| c.kind.point_ids()).collect(); + for p in used { + assert!(s.placed_by[p].is_some_and(Placement::anchors), "{name}: point {p} ({}) not placed: {s:?}", sk.points[p].id); + } + } + } +} + +/// The seed does not depend on the order of the constraints. +#[test] +fn seed_on_lines_and_rounds_is_independent_of_constraint_order() { + let mut rnd = Lcg(13); + for (name, family) in families() { + let case = sample(&mut rnd, family, 0.5, 2.0, 1).remove(0); + let mut sk = edited(&case); + let forward = seed_of(&sk); + sk.constraints.reverse(); + assert_eq!(seed_of(&sk), forward, "{name}"); + let n = sk.constraints.len(); + sk.constraints.rotate_left(n / 3); + assert_eq!(seed_of(&sk), forward, "{name}"); + } +} + +/// The canonical bracket with the third dimension on a point the rigid triangle carries +/// through a constraint the decomposition reads from the shared loci: the midpoint of P R, or +/// the centre of a circle tangent to P R at a known distance from Q. No propagation order +/// reaches P, Q, R; the decomposition grows the triangle by that point and places all four. +#[test] +fn decomposition_grows_clusters_through_lines_and_tangency() { + for tangent in [false, true] { + let truth = [v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)]; + let mut sk = Sketch::new(); + let ids: Vec = truth + .iter() + .enumerate() + .map(|(i, t)| sk.add_point(*t + if i < 2 { v(0.0, 0.0) } else { v(0.7 * i as f64, -0.4 * i as f64) }, None).unwrap()) + .collect(); + let [o1, o2, p, q, r] = [ids[0], ids[1], ids[2], ids[3], ids[4]]; + for x in [o1, o2] { + push(&mut sk, ConstraintKind::Fix { p: x }); + } + let pr = sk.add_line_pts(p, r, None).unwrap(); + for (a, b, ta, tb) in [(p, q, 2, 3), (q, r, 3, 4), (o1, p, 0, 2), (o1, r, 0, 4)] { + push(&mut sk, ConstraintKind::Distance { p: a, q: b, value: truth[ta].dist(truth[tb]) }); + } + push(&mut sk, ConstraintKind::Distance { p, q: r, value: truth[2].dist(truth[4]) }); + let m = if tangent { + // Circle of radius 6 tangent to P R on Q's side, 25 from Q. + let (a, b) = (truth[2], truth[4]); + let d = (b - a).normalized().unwrap(); + let n = d.perp() * (d.cross(truth[3] - a).signum()); + let foot = a + d * 6.0 + n * 6.0; + let k_at = { + // On the offset line, 25 from Q. + let rel = foot - truth[3]; + let t = -rel.dot(d) + (625.0 - rel.cross(d).powi(2)).sqrt(); + foot + d * t + }; + let circle = sk.add_circle(k_at + v(0.5, 0.3), 6.0, None, None).unwrap(); + let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() }; + push(&mut sk, ConstraintKind::Radius { c: circle, value: 6.0 }); + push(&mut sk, ConstraintKind::Tangent { a: pr, b: circle }); + push(&mut sk, ConstraintKind::Distance { p: q, q: c, value: 25.0 }); + push(&mut sk, ConstraintKind::Distance { p: o2, q: c, value: truth[1].dist(k_at) }); + c + } else { + let m = sk.add_point((truth[2] + truth[4]) * 0.5 + v(0.4, 0.2), None).unwrap(); + push(&mut sk, ConstraintKind::Midpoint { p: m, l: pr }); + push(&mut sk, ConstraintKind::Distance { p: o2, q: m, value: truth[1].dist((truth[2] + truth[4]) * 0.5) }); + m + }; + let out = seed_of(&sk); + for x in [p, q, r, m] { + assert_eq!(out.placed_by[x], Some(Placement::Cluster), "tangent {tangent}, point {x}: {out:?}"); + } + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 0 && rep.iterations == 0, "tangent {tangent}: {rep:?}"); + for (i, t) in [(p, 2), (q, 3), (r, 4)] { + assert!(sk.points[i].pos.dist(truth[t]) < 1e-9, "tangent {tangent}: {:?}", sk.points[i].pos); + } + } +} + +/// A circle's radius known from a dimension is part of the seed: the circle's radius starts +/// there. +#[test] +fn a_dimensioned_radius_is_seeded() { + let mut rnd = Lcg(17); + let case = bolt_circle(&mut rnd, 2.0, 2.0).unwrap(); + let sk = edited(&case); + let s = seed_of(&sk); + let ConstraintKind::Diameter { c, value } = sk.constraints[1].kind else { panic!() }; + assert_eq!(s.radius[c], Some(value / 2.0)); +} + +/// Hostile curve references and values in the constraints the new loci read neither panic +/// nor change the report (every solve runs through the seed-on/seed-off comparison). +#[test] +fn hostile_lines_and_rounds_neither_panic_nor_change_the_report() { + let base = || tangent_chain(&mut Lcg(21), 0.5, 2.0).unwrap(); + let raw = |sk: &mut Sketch, kind: ConstraintKind| { + sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None }) + }; + let cases: Vec> = vec![ + Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: 1, l: 999 })), + Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: usize::MAX, l: 0 })), + Box::new(|sk| raw(sk, ConstraintKind::Symmetric { p: 1, q: 2, l: usize::MAX })), + Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 0, b: 0 })), + Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 2, b: 77 })), + Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: f64::NAN })), + Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: -3.0 })), + Box::new(|sk| raw(sk, ConstraintKind::Diameter { c: 0, value: 1e300 })), + Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: 2 })), + Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: usize::MAX })), + Box::new(|sk| raw(sk, ConstraintKind::PointLineDistance { p: 1, l: 1, value: f64::INFINITY })), + Box::new(|sk| raw(sk, ConstraintKind::LineAngle { l: 0, value: f64::NAN })), + Box::new(|sk| raw(sk, ConstraintKind::Angle { a: 0, b: 1, value: 1e300, flip: true })), + Box::new(|sk| raw(sk, ConstraintKind::Parallel { a: 0, b: 0 })), + Box::new(|sk| raw(sk, ConstraintKind::Perpendicular { a: 0, b: 2 })), + Box::new(|sk| raw(sk, ConstraintKind::Equal { a: 0, b: 2 })), + Box::new(|sk| raw(sk, ConstraintKind::Concentric { a: 2, b: 2 })), + Box::new(|sk| raw(sk, ConstraintKind::SlotOverallLength { a: 2, b: 2, value: 5.0 })), + Box::new(|sk| raw(sk, ConstraintKind::LinearDiameter { p: 3, l: 0, value: -1.0 })), + Box::new(|sk| sk.points[2].pos = v(f64::NAN, 0.0)), + Box::new(|sk| sk.points[3].pos = v(1e300, -1e300)), + ]; + for (i, case) in cases.iter().enumerate() { + let mut c = base(); + case(&mut c.sk); + let sk = edited(&c); + let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full()); + assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}"); + assert!(s.radius.iter().flatten().all(|r| r.is_finite() && *r > 0.0), "case {i}: {s:?}"); + let mut t = c.sk.clone(); + solve(&mut t); + } +} + +/// An axis-aligned line angle turned half a turn (0° → 180°) with a line at an angle to it and +/// a tangent arc: the explicit angle decides the direction, not the reference's axis sign, so +/// the seed builds the chain forward along the new angle and nothing folds. +#[test] +fn an_axis_aligned_line_angle_turned_half_a_turn_is_built_along_it() { + let case = chain(&mut Lcg(31), 1.0, 1.0, 0.0, std::f64::consts::PI).unwrap(); + let sk = edited(&case); + let (i0, i1) = sk.curves[0].kind.ends().unwrap(); + let s = seed_of(&sk); + let (p0, e1) = (sk.points[i0].pos, s.pos[i1].unwrap_or_else(|| panic!("L1's end unplaced: {s:?}"))); + assert!((e1 - p0).dot(Vec2::X) < 0.0, "L1 built along the reference's axis, not its angle: {p0:?} → {e1:?}"); + assert_eq!(run(&case, true), (false, false)); +} + +/// Horizontal and vertical lines tied by a contradictory parallel (or a perpendicular between +/// two horizontal lines) are still read as horizontal and vertical one by one: their rows and +/// columns are seeded. +#[test] +fn contradictory_levels_still_read_each_line_on_its_own() { + for perpendicular in [false, true] { + let mut sk = Sketch::new(); + let l1 = sk.add_line(v(0.0, 0.0), v(40.0, 0.5), None, None, None).unwrap(); + let (a, b) = sk.curves[l1].kind.ends().unwrap(); + let l2 = if perpendicular { + sk.add_line(v(0.0, 30.0), v(30.0, 30.4), None, None, None).unwrap() + } else { + sk.add_line(v(0.0, 30.0), v(0.3, 60.0), None, None, None).unwrap() + }; + let (c, d) = sk.curves[l2].kind.ends().unwrap(); + for p in [a, c] { + push(&mut sk, ConstraintKind::Fix { p }); + } + push(&mut sk, ConstraintKind::Horizontal { l: l1 }); + push(&mut sk, ConstraintKind::Length { l: l1, value: 40.0 }); + push(&mut sk, if perpendicular { ConstraintKind::Horizontal { l: l2 } } else { ConstraintKind::Vertical { l: l2 } }); + push(&mut sk, ConstraintKind::Length { l: l2, value: 30.0 }); + push(&mut sk, if perpendicular { ConstraintKind::Perpendicular { a: l1, b: l2 } } else { ConstraintKind::Parallel { a: l1, b: l2 } }); + let s = seed_of(&sk); + assert!(s.pos[b].is_some_and(|p| p.dist(v(40.0, 0.0)) < 1e-9), "perpendicular {perpendicular}: {s:?}"); + let want = if perpendicular { v(30.0, 30.0) } else { v(0.0, 60.0) }; + assert!(s.pos[d].is_some_and(|p| p.dist(want) < 1e-9), "perpendicular {perpendicular}: {s:?}"); + } +} + +/// A dimensioned circle's radius is seeded also when nothing is given to build on. +#[test] +fn a_dimensioned_radius_is_seeded_with_nothing_given() { + let mut sk = Sketch::new(); + let circle = sk.add_circle(v(10.0, 5.0), 10.0, None, None).unwrap(); + push(&mut sk, ConstraintKind::Diameter { c: circle, value: 30.0 }); + let s = seed(&sk, &vec![false; sk.points.len()], None, &[], &mut Budget::full()); + assert_eq!(s.radius[circle], Some(15.0)); +} + +/// A line's end constrained onto a circle is the tangency's touch point only through the +/// solve's point-on-curve index: with it the circle's centre is placed on the normal there, +/// without it only a touching pair of loci is left. +#[test] +fn the_touch_point_comes_from_the_point_on_curve_index() { + let mut sk = Sketch::new(); + let l = sk.add_line(v(0.0, 0.0), v(40.0, 12.0), None, None, None).unwrap(); + let (a, b) = sk.curves[l].kind.ends().unwrap(); + let d = (v(40.0, 12.0) - v(0.0, 0.0)).normalized().unwrap(); + let circle = sk.add_circle(v(40.0, 12.0) + d.perp() * 8.5, 8.0, None, None).unwrap(); + let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() }; + push(&mut sk, ConstraintKind::Fix { p: a }); + push(&mut sk, ConstraintKind::LineAngle { l, value: 12f64.atan2(40.0) }); + push(&mut sk, ConstraintKind::Length { l, value: v(40.0, 12.0).len() }); + push(&mut sk, ConstraintKind::Radius { c: circle, value: 8.0 }); + push(&mut sk, ConstraintKind::Tangent { a: l, b: circle }); + push(&mut sk, ConstraintKind::PointOnCurve { p: b, c: circle }); + let on_curve = crate::solver::points_on_curves(&sk); + let with = seed(&sk, &given(&sk), None, &on_curve, &mut Budget::full()); + assert!(with.placed_by[c].is_some_and(Placement::anchors), "{with:?}"); + assert!(with.pos[c].unwrap().dist(v(40.0, 12.0) + d.perp() * 8.0) < 1e-9, "{with:?}"); + let without = seed(&sk, &given(&sk), None, &[], &mut Budget::full()); + assert!(!without.placed_by[c].is_some_and(Placement::anchors), "{without:?}"); +} + +/// Many pair clusters each grown by one shared point: a fixed point on every line from another +/// fixed point to a point at a distance from it. Each cluster grows the shared point and then +/// merges with the ground, and every growth requeues the clusters holding that point, so the +/// steps outnumber any multiple of the starting clusters: the merge loop is bounded by its +/// work budget, not by a count of steps, and places every point. +#[test] +fn long_growth_chains_are_not_starved() { + let (o, u) = (v(5.0, -3.0), Vec2::from_angle(0.4)); + let mut sk = Sketch::new(); + let p = sk.add_point(o, None).unwrap(); + let z = sk.add_point(o + u * 12.0, None).unwrap(); + for x in [p, z] { + push(&mut sk, ConstraintKind::Fix { p: x }); + } + push(&mut sk, ConstraintKind::Distance { p, q: z, value: 12.0 }); + let qs: Vec = (0..40) + .map(|j| { + let d = 20.0 + 3.0 * j as f64; + let q = sk.add_point(o + u * d + v(0.5, -0.4), None).unwrap(); + let l = sk.add_line_pts(p, q, None).unwrap(); + push(&mut sk, ConstraintKind::Distance { p, q, value: d }); + push(&mut sk, ConstraintKind::PointOnCurve { p: z, c: l }); + q + }) + .collect(); + let s = seed_of(&sk); + for (j, q) in qs.iter().enumerate() { + assert_eq!(s.placed_by[*q], Some(Placement::Cluster), "point {j}: {s:?}"); + assert!(s.pos[*q].is_some_and(|at| at.dist(o + u * (20.0 + 3.0 * j as f64)) < 1e-9), "point {j}: {s:?}"); + } +} diff --git a/crates/sketch/src/construct_tests.rs b/crates/sketch/src/construct_tests.rs new file mode 100644 index 00000000..5eb97789 --- /dev/null +++ b/crates/sketch/src/construct_tests.rs @@ -0,0 +1,1090 @@ +//! Harness for the constructive seed: branch preservation on dimension edits, no freezing of +//! points that keep a degree of freedom, drag continuity, and hostile input. Deterministic +//! (fixed LCG seeds), no timing. + +use solvecraft_geom::Vec2; + +use crate::construct::{Placement, seed}; +use crate::decompose::Budget; +use crate::solver::points_on_curves; +use crate::tests::{Lcg, given, seed_of, seeded, v}; +use crate::*; + +fn orientation(a: Vec2, b: Vec2, c: Vec2) -> f64 { + (b - a).cross(c - a).signum() +} + +/// Fan-triangulated k-gon (diagonals from vertex 0), vertices 0 and 1 fixed, every other +/// dimension scaled by a factor in `lo..hi`; only edits where every fan triangle still exists +/// are kept, so an orientation-preserving solution always exists. Returns (failed, mirrored) +/// over 500 edits. +fn fan(rnd: &mut Lcg, k: usize, lo: f64, hi: f64, on: bool) -> (usize, usize) { + let (mut failed, mut mirrored, mut kept) = (0, 0, 0); + while kept < 500 { + let mut sk = Sketch::new(); + let ids: Vec = (0..k) + .map(|i| { + let t = i as f64 / k as f64 * std::f64::consts::TAU; + let r = 30.0 + rnd.next() * 10.0; + sk.add_point(v(r * t.cos(), r * t.sin()), None).unwrap() + }) + .collect(); + let pos = |sk: &Sketch, i: usize| sk.points[ids[i]].pos; + let orient = |sk: &Sketch| (1..k - 1).map(|i| orientation(pos(sk, 0), pos(sk, i), pos(sk, i + 1))).collect::>(); + let before = orient(&sk); + let mut dist = std::collections::HashMap::new(); + for i in 1..k { + let j = (i + 1) % k; + dist.insert((i, j), pos(&sk, i).dist(pos(&sk, j)) * (lo + rnd.next() * (hi - lo))); + } + dist.insert((0, 1), pos(&sk, 0).dist(pos(&sk, 1))); + for i in 2..k { + dist.insert((0, i), pos(&sk, 0).dist(pos(&sk, i)) * (lo + rnd.next() * (hi - lo))); + } + dist.insert((0, k - 1), dist[&(k - 1, 0)]); + let feasible = (1..k - 1).all(|i| { + let (a, b, c) = (dist[&(0, i)], dist[&(i, i + 1)], dist[&(0, i + 1)]); + a + b > c * 1.02 && a + c > b * 1.02 && b + c > a * 1.02 + }); + if !feasible { + continue; + } + kept += 1; + for &p in &ids[..2] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + for i in 1..k { + let j = (i + 1) % k; + sk.add_constraint(ConstraintKind::Distance { p: ids[i], q: ids[j], value: dist[&(i, j)] }, None).unwrap(); + } + for i in 2..k - 1 { + sk.add_constraint(ConstraintKind::Distance { p: ids[0], q: ids[i], value: dist[&(0, i)] }, None).unwrap(); + } + let rep = seeded(on, || solve(&mut sk)); + if !rep.ok() { + failed += 1; + } else if orient(&sk) != before { + mirrored += 1; + } + } + (failed, mirrored) +} + +fn fan_table(on: bool) -> Vec<(f64, f64, usize, usize, usize)> { + let mut rnd = Lcg(99); + let mut out = Vec::new(); + for (lo, hi) in [(0.9, 1.15), (0.7, 1.6), (0.5, 2.5)] { + for k in [4usize, 5, 6, 8, 12] { + let (failed, mirrored) = fan(&mut rnd, k, lo, hi, on); + out.push((lo, hi, k, failed, mirrored)); + } + } + out +} + +#[test] +fn fan_polygons_keep_every_triangle_on_dimension_edits() { + let table = fan_table(true); + for (lo, hi, k, failed, mirrored) in &table { + eprintln!("seed on {lo}–{hi}× {k:>2}-gon: {failed} failed, {mirrored}/500 mirrored"); + } + assert!(table.iter().all(|row| row.3 == 0 && row.4 == 0), "{table:?}"); +} + +/// The measurement the seed is judged against: without it, LM mirrors fan triangles. +#[test] +fn fan_polygons_mirror_without_the_seed() { + let table = fan_table(false); + for (lo, hi, k, failed, mirrored) in &table { + eprintln!("seed off {lo}–{hi}× {k:>2}-gon: {failed} failed, {mirrored}/500 mirrored"); + } + let wide_12 = table.iter().find(|r| r.0 == 0.5 && r.2 == 12).map(|r| r.4).unwrap(); + assert!(wide_12 > 100, "{table:?}"); +} + +/// Base fixed, apex near the base, both sides edited; also far from the origin. +fn triangle_edits(on: bool) -> Vec<(f64, usize, usize)> { + let mut rnd = Lcg(99); + let mut out = Vec::new(); + for off in [0.0, 1e4, 1e6] { + let (mut flips, mut fails) = (0, 0); + for _ in 0..2000 { + let o = v(off, off); + let mut sk = Sketch::new(); + let a = sk.add_point(o, None).unwrap(); + let b = sk.add_point(o + v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(o + v(5.0 + rnd.next() * 30.0, 0.05 + rnd.next() * 3.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + let pc = sk.points[c].pos; + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: pc.dist(o) }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: pc.dist(o + v(40.0, 0.0)) }, None).unwrap(); + let (r1, r2) = loop { + let (r1, r2) = (5.0 + rnd.next() * 60.0, 5.0 + rnd.next() * 60.0); + if r1 + r2 > 41.0 && (r1 - r2).abs() < 39.0 { + break (r1, r2); + } + }; + sk.constraints[2].kind.set_value(r1); + sk.constraints[3].kind.set_value(r2); + if !seeded(on, || solve(&mut sk)).ok() { + fails += 1; + } else if sk.points[c].pos.y < off { + flips += 1; + } + } + out.push((off, flips, fails)); + } + out +} + +#[test] +fn triangle_edits_keep_the_apex_side_far_from_the_origin() { + for on in [false, true] { + for (off, flips, fails) in triangle_edits(on) { + eprintln!("seed {} triangle at offset {off:e}: {flips}/2000 mirrored, {fails} failed", if on { "on " } else { "off" }); + if on { + assert_eq!((flips, fails), (0, 0), "offset {off:e}"); + } + } + } +} + +/// Two triangles joined by three links: no point beyond the first triangle has two loci on +/// placed points, so the seed must leave them to LM (projecting each onto its one locus would +/// freeze a point that still has a degree of freedom). +fn linkage() -> (Sketch, Vec) { + const TRUE: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)]; + const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)]; + let mut sk = Sketch::new(); + let ids: Vec = TRUE + .iter() + .enumerate() + .map(|(i, p)| { + let at = if i == 0 { *p } else { (p.0 + 1.5 * ((i * 7 % 5) as f64 - 2.0), p.1 - 1.2 * ((i * 3 % 4) as f64 - 1.5)) }; + sk.add_point(v(at.0, at.1), None).unwrap() + }) + .collect(); + sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap(); + let l = sk.add_line_pts(ids[0], ids[1], None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + for (a, b) in PAIRS { + let value = v(TRUE[a].0, TRUE[a].1).dist(v(TRUE[b].0, TRUE[b].1)); + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value }, None).unwrap(); + } + (sk, ids) +} + +/// Two triangles joined by three links (as [`linkage`]), every distance scaled by 0.5–2.5×. +fn edited_linkage(rnd: &mut Lcg) -> Sketch { + let (mut sk, _) = linkage(); + for c in sk.constraints.iter_mut() { + if let Some(v) = c.kind.value() { + c.kind.set_value(v * (0.5 + rnd.next() * 2.0)); + } + } + sk +} + +/// Pinning the base triangle exactly can leave LM stuck on the rest where the plain start +/// solves; the solve then falls back to the plain start, so the seed never loses a solution. +#[test] +fn edited_linkages_never_fail_where_the_plain_start_solves() { + let mut rnd = Lcg(7); + let (mut lost, mut gained) = (Vec::new(), Vec::new()); + for i in 0..500 { + let sk = edited_linkage(&mut rnd); + let (mut a, mut b) = (sk.clone(), sk.clone()); + let on = seeded(true, || solve(&mut a)).ok(); + let off = seeded(false, || solve(&mut b)).ok(); + if off && !on { + lost.push(i); + } + if on && !off { + gained.push(i); + } + } + eprintln!("edited linkages: seeded solves {} the plain start does not ({gained:?}), loses {lost:?}", gained.len()); + assert!(lost.is_empty(), "{lost:?}"); +} + +/// An edited linkage (case 28 of the family above) where LM from the seeded start alone fails +/// and the plain start solves. +#[test] +fn edited_linkage_the_seeded_start_alone_cannot_solve() { + let (mut sk, _) = linkage(); + let values = [ + 46.108603805595436, + 66.03877723361462, + 44.29474034815241, + 49.872556594908566, + 70.62035270311223, + 42.30949365900061, + 95.95247718421143, + 62.63491114263336, + 39.69273507189351, + ]; + for (c, v) in sk.constraints.iter_mut().filter(|c| c.kind.value().is_some()).zip(values) { + c.kind.set_value(v); + } + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); +} + +#[test] +fn triconnected_linkage_still_solves_exactly() { + let (mut sk, ids) = linkage(); + let s = seed_of(&sk); + assert_eq!(s.placed_by[ids[0]], Some(Placement::Given)); + assert!(matches!(s.placed_by[ids[1]], Some(Placement::TwoLoci { transversal: true, .. }))); + assert!(matches!(s.placed_by[ids[2]], Some(Placement::TwoLoci { transversal: true, .. }))); + for &p in &ids[3..] { + assert_eq!(s.placed_by[p], None, "point {p} has one locus only"); + } + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + assert_eq!(rep.dof, 0); + let worst = sk + .constraints + .iter() + .filter_map(|c| match c.kind { + ConstraintKind::Distance { p, q, value } => Some((sk.points[p].pos.dist(sk.points[q].pos) - value).abs()), + _ => None, + }) + .fold(0.0, f64::max); + eprintln!("linkage: {} iterations, worst distance error {worst:e}", rep.iterations); + assert!(worst < 1e-9, "{worst}"); +} + +/// n×n grid, origin fixed, every row and column edge horizontal/vertical with a distance, +/// started ±1 mm off. +fn grid(n: usize) -> Sketch { + let mut sk = Sketch::new(); + let mut rnd = Lcg(12345); + let mut id = vec![vec![0; n]; n]; + for (r, row) in id.iter_mut().enumerate() { + for (c, slot) in row.iter_mut().enumerate() { + let j = if r == 0 && c == 0 { v(0.0, 0.0) } else { v(rnd.next() * 2.0 - 1.0, rnd.next() * 2.0 - 1.0) }; + *slot = sk.add_point(v(c as f64 * 10.0, r as f64 * 10.0) + j, None).unwrap(); + } + } + sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None).unwrap(); + let rows = id.iter().flat_map(|row| row.windows(2).map(|w| (w[0], w[1], true))); + let cols = (0..n).flat_map(|c| id.windows(2).map(move |w| (w[0][c], w[1][c], false))); + for (p, q, horizontal) in rows.chain(cols).collect::>() { + let l = sk.add_line_pts(p, q, None).unwrap(); + sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None).unwrap(); + } + sk +} + +#[test] +fn grid_from_a_perturbed_start_is_constructed_whole() { + let mut sk = grid(20); + let s = seed_of(&sk); + // Point 0 is the sketch origin, point 1 the fixed grid corner. + assert_eq!(s.placed_by[1], Some(Placement::Given)); + assert!(s.placed_by.iter().skip(2).all(|p| matches!(p, Some(Placement::TwoLoci { transversal: true, .. })))); + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + assert_eq!(rep.dof, 0); + eprintln!("20×20 grid ±1 mm: {} iterations", rep.iterations); + for (i, p) in sk.points.iter().enumerate().skip(1) { + let (r, c) = ((i - 1) / 20, (i - 1) % 20); + assert!(p.pos.dist(v(c as f64 * 10.0, r as f64 * 10.0)) < 1e-9, "{i}: {:?}", p.pos); + } +} + +/// Every grid point is constructed transversally from two loci and its rows hold there: LM +/// gets no variable, and the rank analysis eliminates them all. +#[test] +fn constructed_grid_leaves_nothing_to_lm() { + let sk = grid(20); + let mut solved = sk.clone(); + crate::solver::lm_vars::take(); + let rep = seeded(true, || solve(&mut solved)); + assert_eq!(crate::solver::lm_vars::take(), 0); + assert!(rep.ok() && rep.dof == 0 && rep.point_determined.iter().all(|d| *d), "{rep:?}"); + // Without the seed LM solves all of them, to the same outcome. + let mut plain = sk.clone(); + let without = seeded(false, || solve(&mut plain)); + assert_eq!(crate::solver::lm_vars::take(), 2 * (20 * 20 - 1)); + assert_eq!((without.status, without.dof, &without.point_determined), (rep.status, rep.dof, &rep.point_determined)); + for (a, b) in solved.points.iter().zip(&plain.points) { + assert!(a.pos.dist(b.pos) < 1e-6, "{:?} {:?}", a.pos, b.pos); + } +} + +/// A point the construction cannot place (one locus only) is all LM iterates on: the +/// constructed grid it hangs off stays where the seed put it, as constants. +#[test] +fn lm_iterates_on_what_the_construction_leaves() { + let mut sk = grid(8); + let corner = sk.points.len() - 1; + let q = sk.add_point(sk.points[corner].pos + v(7.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: corner, q, value: 5.0 }, None).unwrap(); + crate::solver::lm_vars::take(); + let rep = seeded(true, || solve(&mut sk.clone())); + assert_eq!(crate::solver::lm_vars::take(), 2, "only the hanging point"); + assert!(rep.ok() && rep.dof == 1, "{rep:?}"); + assert!(!rep.point_determined[q] && rep.point_determined[corner]); + // The outcome is the one of the unseeded solve, which iterates on every point. + solve(&mut sk); +} + +/// A pinned solve that fails is followed by one whole-component solve, from where the +/// component would have started without the seed: never again from the seeded start it failed +/// from. Here the hanging point is held at two distances from one corner. +#[test] +fn a_failed_pinned_solve_is_retried_once() { + let mut sk = grid(4); + let corner = sk.points.len() - 1; + let q = sk.add_point(sk.points[corner].pos + v(7.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: corner, q, value: 5.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: corner, q, value: 9.0 }, None).unwrap(); + crate::solver::lm_vars::runs(); + let rep = seeded(true, || solve(&mut sk.clone())); + assert!(!rep.ok(), "{rep:?}"); + assert_eq!(crate::solver::lm_vars::runs(), 2, "the pinned solve and the unseeded retry"); +} + +/// Move point `q` to `to` holding it, as a drag does; `reference` from before the move. +fn drag(sk: &mut Sketch, q: usize, to: Vec2, with_reference: bool) -> bool { + let before: Vec = sk.points.iter().map(|p| p.pos).collect(); + let mut t = sk.clone(); + t.points[q].pos = to; + let hold = Hold { points: vec![q], radii: vec![] }; + let ok = solve_with(&mut t, &hold, with_reference.then_some(before.as_slice()), Analysis::Full, None).ok(); + if ok { + *sk = t; + } + ok +} + +#[test] +fn dragging_a_base_across_and_back_returns_to_the_start() { + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(v(20.0, 15.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 25.0 }, None).unwrap(); + let start = sk.points[c].pos; + let side = |sk: &Sketch| orientation(sk.points[a].pos, sk.points[b].pos, sk.points[c].pos); + for with_reference in [true, false] { + let mut t = sk.clone(); + assert!(drag(&mut t, b, v(-40.0, 0.0), with_reference)); + // Measured on the moved base, the apex has already changed side: only the reference + // knows which side it was on. + if with_reference { + assert_eq!(side(&t), side(&sk), "the apex kept its side of the base"); + } + assert!(drag(&mut t, b, v(40.0, 0.0), with_reference)); + assert!(t.points[c].pos.dist(start) < 1e-7, "{:?}", t.points[c].pos); + } +} + +/// Two-link arm: shoulder fixed, hand held at the cursor, one-jump drags along paths clear of +/// the arm's singular poses. The links are dimensioned, or (`equal`) the upper arm is and the +/// forearm is held equal to it. Returns elbow flips over 1000 drags. +fn elbow_flips(with_reference: bool, on: bool, equal: bool) -> usize { + let mut rnd = Lcg(11); + let (a, b) = if equal { (25.0, 25.0) } else { (30.0, 20.0) }; + let (lo, hi) = ((a - b) + 3.0, a + b - 3.0); + let (mut flips, mut kept) = (0, 0); + while kept < 1000 { + let th1 = rnd.next() * std::f64::consts::TAU; + let bend = (0.4 + rnd.next() * 2.2) * if rnd.next() < 0.5 { 1.0 } else { -1.0 }; + let v2 = Vec2::from_angle(th1) * a; + let v3 = v2 + Vec2::from_angle(th1 + bend) * b; + let tr = lo + rnd.next() * (hi - lo); + let to = Vec2::from_angle(rnd.next() * std::f64::consts::TAU) * tr; + if !(0..=100).all(|i| (lo..=hi).contains(&v3.lerp(to, i as f64 / 100.0).len())) { + continue; + } + kept += 1; + let mut sk = Sketch::new(); + let p1 = sk.add_point(Vec2::ZERO, None).unwrap(); + let p2 = sk.add_point(v2, None).unwrap(); + let p3 = sk.add_point(v3, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: p1 }, None).unwrap(); + let upper = sk.add_line_pts(p1, p2, None).unwrap(); + let fore = sk.add_line_pts(p2, p3, None).unwrap(); + if equal { + sk.add_constraint(ConstraintKind::Length { l: upper, value: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Equal { a: upper, b: fore }, None).unwrap(); + } else { + sk.add_constraint(ConstraintKind::Distance { p: p1, q: p2, value: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: p2, q: p3, value: b }, None).unwrap(); + } + let side = |sk: &Sketch| orientation(sk.points[p1].pos, sk.points[p3].pos, sk.points[p2].pos); + let before = side(&sk); + seeded(on, || drag(&mut sk, p3, to, with_reference)); + if side(&sk) != before { + flips += 1; + } + } + flips +} + +#[test] +fn one_jump_drag_keeps_the_elbow_with_the_reference() { + let off = elbow_flips(false, false, false); + let current = elbow_flips(false, true, false); + let reference = elbow_flips(true, true, false); + eprintln!("two-link arm, one jump: {off}/1000 flips without seed, {current} seeded on current positions, {reference} seeded on the reference"); + assert_eq!(reference, 0); +} + +/// The forearm's length comes from an Equal to the dimensioned upper arm: the seed reads it, +/// so a one-jump drag keeps the elbow too. +#[test] +fn one_jump_drag_keeps_an_equal_linked_elbow() { + let off = elbow_flips(true, false, true); + let reference = elbow_flips(true, true, true); + eprintln!("equal-linked arm, one jump: {off}/1000 flips without seed, {reference} seeded on the reference"); + assert_eq!(reference, 0); +} + +/// A fillet in a corner: L1 horizontal from a fixed end, arc tangent to L1 and L2, radius 5. +fn fillet() -> (Sketch, usize, usize, usize) { + let mut sk = Sketch::new(); + let l1 = sk.add_line(v(40.0, 0.0), v(5.0, 0.0), None, None, None).unwrap(); + let (s1, e1) = sk.curves[l1].kind.ends().unwrap(); + let l2 = sk.add_line(v(0.0, 5.0), v(0.0, 80.0), None, None, None).unwrap(); + let (s2, e2) = sk.curves[l2].kind.ends().unwrap(); + let arc = sk.add_arc(v(5.0, 5.0), v(0.0, 5.0), v(5.0, 0.0), [None, Some(s2), Some(e1)], None).unwrap(); + let CurveKind::Arc { c, .. } = sk.curves[arc].kind else { panic!() }; + sk.add_constraint(ConstraintKind::Fix { p: s1 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l: l1 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Radius { c: arc, value: 5.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Tangent { a: l1, b: arc }, None).unwrap(); + sk.add_constraint(ConstraintKind::Tangent { a: l2, b: arc }, None).unwrap(); + (sk, c, e2, s2) +} + +#[test] +fn solved_fillet_stays_put_and_stays_in_its_corner_while_dragged() { + let (mut sk, c, e2, s2) = fillet(); + let before = sk.points.clone(); + assert!(solve(&mut sk).ok()); + for (p, q) in sk.points.iter().zip(&before) { + assert!(p.pos.dist(q.pos) < 1e-9, "{} moved to {:?}", p.id, p.pos); + } + let inside = |sk: &Sketch| { + let (cc, s, e) = (sk.points[c].pos, sk.points[s2].pos, sk.points[e2].pos); + cc.y > 0.0 && (e - s).cross(cc - s) < 0.0 + }; + for to in [v(-30.0, 75.0), v(20.0, 75.0), v(-60.0, 40.0), v(0.0, 80.0)] { + for with_reference in [false, true] { + let mut t = sk.clone(); + for k in 1..=10 { + let at = t.points[e2].pos.lerp(to, k as f64 / 10.0); + drag(&mut t, e2, at, with_reference); + assert!(inside(&t), "centre left the corner dragging to {to:?}: {:?}", t.points[c].pos); + } + } + } +} + +#[test] +fn hostile_input_neither_panics_nor_changes_the_report() { + let base = || { + let mut sk = Sketch::new(); + let a = sk.add_point(v(10.0, 0.0), None).unwrap(); + let b = sk.add_point(v(5.0, 8.0), None).unwrap(); + let l = sk.add_line_pts(0, a, None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: 0, q: a, value: 10.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: 0, q: b, value: 9.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: b, value: 9.0 }, None).unwrap(); + (sk, a, b, l) + }; + let push = |sk: &mut Sketch, kind: ConstraintKind| { + sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None }) + }; + let cases: Vec> = vec![ + Box::new(|sk, _, _, _| sk.constraints[1].kind.set_value(f64::NAN)), + Box::new(|sk, _, _, _| sk.constraints[2].kind.set_value(f64::INFINITY)), + Box::new(|sk, _, _, _| sk.constraints[3].kind.set_value(1e300)), + Box::new(|sk, _, _, _| sk.constraints[2].kind.set_value(-4.0)), + Box::new(|sk, _, _, _| sk.constraints[3].kind.set_value(0.0)), + Box::new(|sk, _, b, _| sk.points[b].pos = v(f64::NAN, 1.0)), + Box::new(|sk, a, _, _| sk.points[a].pos = v(f64::INFINITY, 0.0)), + Box::new(|sk, _, b, _| sk.points[b].pos = v(1e300, -1e300)), + Box::new(move |sk, _, b, _| push(sk, ConstraintKind::Distance { p: b, q: 999, value: 3.0 })), + Box::new(move |sk, a, _, _| push(sk, ConstraintKind::DistanceX { p: a, q: usize::MAX, value: 3.0 })), + Box::new(move |sk, _, _, _| push(sk, ConstraintKind::Horizontal { l: 77 })), + Box::new(move |sk, _, b, _| push(sk, ConstraintKind::Coincident { p: b, q: b })), + Box::new(move |sk, _, _, l| push(sk, ConstraintKind::Length { l, value: f64::NAN })), + Box::new(move |sk, a, b, _| push(sk, ConstraintKind::DistanceY { p: a, q: b, value: f64::NEG_INFINITY })), + ]; + // Every solve here runs through the crate-wide seed-on/seed-off comparison (`solve_from`). + for (i, case) in cases.iter().enumerate() { + let (mut sk, a, b, l) = base(); + case(&mut sk, a, b, l); + let mut mask = given(&sk); + mask.push(true); + let s = seed(&sk, &mask, Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full()); + assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}"); + let n = sk.points.len(); + for reference in [None, Some(vec![Vec2::ZERO; 1]), Some(vec![v(f64::NAN, f64::INFINITY); n]), Some(vec![Vec2::ZERO; n + 3])] { + let hold = Hold { points: vec![usize::MAX], radii: vec![usize::MAX] }; + let mut t = sk.clone(); + match &reference { + Some(r) => solve_with(&mut t, &hold, Some(r), Analysis::Full, None), + None => solve_with(&mut t, &hold, None, Analysis::Full, None), + }; + } + } +} + +/// A point whose second anchor is placed after it was first examined (it comes earlier in the +/// point list than that anchor) is examined again and placed. +#[test] +fn points_out_of_dependency_order_are_seeded() { + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let d = sk.add_point(v(-10.0, 25.0), None).unwrap(); + let b = sk.add_point(v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(v(20.0, 15.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 26.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 26.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: d, value: 28.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: c, q: d, value: 30.0 }, None).unwrap(); + let s = seed_of(&sk); + assert!(matches!(s.placed_by[c], Some(Placement::TwoLoci { transversal: true, .. })), "{s:?}"); + assert_eq!(s.placed_by[d], Some(Placement::TwoLoci { a, b: c, transversal: true }), "{s:?}"); +} + +/// Tangency and concentricity are judged on the loci's own size, not on how far from the +/// origin they are. +#[test] +fn far_from_the_origin_a_flat_triangle_still_has_two_branches() { + // Apex 0.05 mm above a 40 mm base, 1e5 mm from the origin. + let o = v(1e5, 1e5); + let mut sk = Sketch::new(); + let a = sk.add_point(o, None).unwrap(); + let b = sk.add_point(o + v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(o + v(20.0, 0.05), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + let r = v(20.0, 0.05).len(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: r }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: r }, None).unwrap(); + let s = seed_of(&sk); + assert_eq!(s.placed_by[c], Some(Placement::TwoLoci { a, b, transversal: true })); + assert!(s.pos[c].unwrap().dist(o + v(20.0, 0.05)) < 1e-6, "{:?}", s.pos[c]); + + // Circles about centres 1 mm apart, 1e6 mm from the origin, are not concentric. + let o = v(1e6, 1e6); + let mut sk = Sketch::new(); + let a = sk.add_point(o, None).unwrap(); + let b = sk.add_point(o + v(1.0, 0.0), None).unwrap(); + let c = sk.add_point(o + v(0.4, -9.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 10.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 10.0 }, None).unwrap(); + let s = seed_of(&sk); + assert_eq!(s.placed_by[c], Some(Placement::TwoLoci { a, b, transversal: true })); + assert!(s.pos[c].unwrap().dist(o + v(0.5, -(100.0f64 - 0.25).sqrt())) < 1e-6, "{:?}", s.pos[c]); +} + +/// Loci that only touch leave the point where it was: the solve is exactly the unseeded one. +#[test] +fn touching_loci_are_left_to_lm() { + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(v(18.0, 3.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 15.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 25.0 }, None).unwrap(); + let s = seed_of(&sk); + assert_eq!(s.placed_by[c], Some(Placement::TwoLoci { a, b, transversal: false })); + let (mut on, mut off) = (sk.clone(), sk.clone()); + let with = seeded(true, || solve(&mut on)); + let without = seeded(false, || solve(&mut off)); + assert_eq!(on.points, off.points); + assert_eq!((with.status, with.iterations), (without.status, without.iterations)); +} + +/// The canonical bracket (Owen's articulation-pair case) behind a row–column point, with a +/// point on top: O2 is placed by propagation (level with O1, under F), P Q R only by the +/// decomposition (none has two placed neighbours), and S by propagation again (level with P, +/// under Q: relations the decomposition does not combine). Returns the sketch and +/// [O1, F, O2, P, Q, R, S]. +fn bracket_chain() -> (Sketch, [usize; 7]) { + let truth = [v(0.0, 0.0), v(60.0, 40.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0), v(45.0, 30.0)]; + let mut sk = Sketch::new(); + let mut ids = [0; 7]; + for (i, t) in truth.iter().enumerate() { + // The free points start a few mm off, as after an edit. + let off = if i < 2 { v(0.0, 0.0) } else { v(1.5 * (i as f64 - 4.0), 1.0 - 0.5 * i as f64) }; + ids[i] = sk.add_point(*t + off, None).unwrap(); + } + let [o1, f, o2, p, q, r, s] = ids; + for x in [o1, f] { + sk.add_constraint(ConstraintKind::Fix { p: x }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::HorizontalPoints { p: o1, q: o2 }, None).unwrap(); + sk.add_constraint(ConstraintKind::VerticalPoints { p: f, q: o2 }, None).unwrap(); + let at = |x: usize| truth[ids.iter().position(|i| *i == x).unwrap()]; + sk.add_constraint(ConstraintKind::HorizontalPoints { p, q: s }, None).unwrap(); + sk.add_constraint(ConstraintKind::VerticalPoints { p: q, q: s }, None).unwrap(); + for (a, b) in [(p, q), (q, r), (p, r), (o1, p), (o1, r), (o2, q)] { + sk.add_constraint(ConstraintKind::Distance { p: a, q: b, value: at(a).dist(at(b)) }, None).unwrap(); + } + (sk, ids) +} + +#[test] +fn propagation_and_decomposition_interleave() { + let (mut sk, [o1, f, o2, p, q, r, s]) = bracket_chain(); + let out = seed_of(&sk); + for x in [o1, f] { + assert_eq!(out.placed_by[x], Some(Placement::Given)); + } + assert!(matches!(out.placed_by[o2], Some(Placement::TwoLoci { transversal: true, .. })), "{out:?}"); + for x in [p, q, r] { + assert_eq!(out.placed_by[x], Some(Placement::Cluster), "point {x}: {out:?}"); + } + assert_eq!(out.placed_by[s], Some(Placement::TwoLoci { a: p, b: q, transversal: true }), "{out:?}"); + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 0, "{rep:?}"); + assert_eq!(rep.iterations, 0, "every point constructed exactly"); +} + +/// A cluster that only fits at a touching intersection is not merged: its points are left to +/// LM, as a touching pair of loci is. +#[test] +fn touching_cluster_merges_are_left_to_lm() { + // P fixed; rigid triangle P Q R; Q–R at a DistanceX. Equal to |QR|, it holds only with + // Q–R level: turning the triangle onto it is a tangency. + let build = |dx: f64| { + let mut sk = Sketch::new(); + let p = sk.add_point(v(10.0, 10.0), None).unwrap(); + let q = sk.add_point(v(0.0, -21.0), None).unwrap(); + let r = sk.add_point(v(31.0, -17.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + let truth = [v(10.0, 10.0), v(-2.0, -20.0), v(30.0, -20.0)]; + let ids = [p, q, r]; + for (a, b) in [(0, 1), (1, 2), (0, 2)] { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: truth[a].dist(truth[b]) }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::DistanceX { p: q, q: r, value: dx }, None).unwrap(); + (sk, q, r) + }; + let (sk, q, r) = build(32.0); + let out = seed_of(&sk); + assert_eq!((out.placed_by[q], out.placed_by[r]), (None, None), "{out:?}"); + let (mut on, mut off) = (sk.clone(), sk.clone()); + let with = seeded(true, || solve(&mut on)); + let without = seeded(false, || solve(&mut off)); + assert_eq!(on.points, off.points); + assert_eq!((with.status, with.iterations), (without.status, without.iterations)); + // Shorter: the triangle turns onto it at a crossing and is placed. + let (sk, q, r) = build(31.0); + let out = seed_of(&sk); + assert_eq!((out.placed_by[q], out.placed_by[r]), (Some(Placement::Cluster), Some(Placement::Cluster)), "{out:?}"); +} + +/// The seed does not depend on the order of the constraints. +#[test] +fn seed_is_independent_of_constraint_order() { + for mut sk in [grid(6), linkage().0, bracket_chain().0] { + let forward = seed_of(&sk); + sk.constraints.reverse(); + assert_eq!(seed_of(&sk), forward); + let n = sk.constraints.len(); + sk.constraints.rotate_left(n / 3); + assert_eq!(seed_of(&sk), forward); + } +} + +/// A reference taken before points were appended covers the points it has; one longer than +/// the point list is ignored. +#[test] +fn short_reference_covers_its_points_and_long_one_is_ignored() { + let arm = || { + let mut sk = Sketch::new(); + let p1 = sk.add_point(v(0.0, 0.0), None).unwrap(); + let p2 = sk.add_point(v(0.0, 30.0), None).unwrap(); + let p3 = sk.add_point(v(20.0, 30.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: p1 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: p1, q: p2, value: 30.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: p2, q: p3, value: 20.0 }, None).unwrap(); + (sk, p1, p2, p3) + }; + let side = |sk: &Sketch, p1: usize, p2: usize, p3: usize| orientation(sk.points[p1].pos, sk.points[p3].pos, sk.points[p2].pos); + let hand_moved = |extra: usize| { + let (mut sk, p1, p2, p3) = arm(); + let before: Vec = sk.points.iter().map(|p| p.pos).collect(); + let start = side(&sk, p1, p2, p3); + sk.add_point(v(5.0, 5.0), None).unwrap(); + sk.points[p3].pos = v(-20.0, 30.0); + let reference: Vec = before.iter().copied().chain(std::iter::repeat_n(Vec2::ZERO, extra)).collect(); + assert!(solve_with(&mut sk, &Hold { points: vec![p3], radii: vec![] }, Some(&reference), Analysis::Full, None).ok()); + (start, side(&sk, p1, p2, p3)) + }; + // Shorter than the sketch (a point appended since): the elbow keeps its side. + let (start, after) = hand_moved(0); + assert_eq!(after, start); + // Longer than the sketch: ignored, the orientation comes from the moved hand. + let (start, after) = hand_moved(5); + assert_ne!(after, start); +} + +/// [`bracket_chain`] with a second bracket on S (placed by propagation after the first +/// decomposition) and Q: P' Q' R' rigid, |S P'|, |S R'|, |Q Q'|. Returns the sketch and +/// [P', Q', R']. +fn two_stage_bracket() -> (Sketch, [usize; 3]) { + let (mut sk, [.., q, _, s]) = bracket_chain(); + let truth = [v(70.0, 20.0), v(85.0, 40.0), v(65.0, 50.0)]; + let ids = truth.map(|t| sk.add_point(t + v(1.0, -1.5), None).unwrap()); + let (sq, qq) = (v(45.0, 30.0), v(45.0, 35.0)); + for (a, b, value) in [ + (ids[0], ids[1], truth[0].dist(truth[1])), + (ids[1], ids[2], truth[1].dist(truth[2])), + (ids[0], ids[2], truth[0].dist(truth[2])), + (s, ids[0], sq.dist(truth[0])), + (s, ids[2], sq.dist(truth[2])), + (q, ids[1], qq.dist(truth[1])), + ] { + sk.add_constraint(ConstraintKind::Distance { p: a, q: b, value }, None).unwrap(); + } + (sk, ids) +} + +/// Propagation placing a point the second bracket hangs on extends the graph: one build. +#[test] +fn the_decomposition_graph_is_reused_across_rounds() { + let (mut sk, ids) = two_stage_bracket(); + let builds = crate::decompose::builds(); + let out = seed_of(&sk); + assert_eq!(crate::decompose::builds() - builds, 1, "one graph for both rounds"); + for x in ids { + assert_eq!(out.placed_by[x], Some(Placement::Cluster), "point {x}: {out:?}"); + } + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 0 && rep.iterations == 0, "{rep:?}"); +} + +/// A long chain of dimensioned links hanging off a fixed point: every free point keeps a degree +/// of freedom and no cluster of three points is rigid, so no graph is built. +#[test] +fn no_graph_is_built_where_the_decomposition_can_place_nothing() { + let mut sk = Sketch::new(); + let mut prev = sk.add_point(v(0.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: prev }, None).unwrap(); + for i in 1..300 { + let p = sk.add_point(v(i as f64 * 10.0, (i % 3) as f64), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: prev, q: p, value: 10.0 }, None).unwrap(); + prev = p; + } + let builds = crate::decompose::builds(); + let out = seed_of(&sk); + assert_eq!(crate::decompose::builds() - builds, 0); + // The sketch's origin and the fixed end are given. + assert!(out.placed_by.iter().skip(2).all(Option::is_none), "{:?}", out.placed_by); +} + +/// Dimensioned rectangles that float (no fixed point) are oriented clusters. Solved, they hold +/// at their reference and no graph is built; after a width edit the edited one is floated by +/// its reference pose (it keeps its centre) and the others are left alone. +#[test] +fn floating_rectangles_are_placed_by_their_reference_pose() { + let mut sk = Sketch::new(); + let mut widths = Vec::new(); + for k in 0..50 { + let o = v(k as f64 * 30.0, 100.0); + let ps = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)].map(|d| sk.add_point(o + d, None).unwrap()); + for i in 0..4 { + let l = sk.add_line_pts(ps[i], ps[(i + 1) % 4], None).unwrap(); + let kind = if i % 2 == 0 { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }; + sk.add_constraint(kind, None).unwrap(); + sk.add_constraint(ConstraintKind::Length { l, value: if i % 2 == 0 { 20.0 } else { 10.0 } }, None).unwrap(); + if i % 2 == 0 { + widths.push(sk.constraints.len() - 1); + } + } + } + let builds = crate::decompose::builds(); + let out = seed_of(&sk); + assert_eq!(crate::decompose::builds() - builds, 0); + assert!(out.placed_by.iter().skip(1).all(Option::is_none), "{:?}", out.placed_by); + // The first rectangle made 26 wide (bottom and top). + for c in &widths[..2] { + if let ConstraintKind::Length { value, .. } = &mut sk.constraints[*c].kind { + *value = 26.0; + } + } + let out = seed_of(&sk); + assert_eq!(crate::decompose::builds() - builds, 1); + // Past the sketch's origin: the first rectangle 3 wider on each side; the others, already + // at their reference pose, are left alone. + for (i, p) in sk.points.iter().enumerate().skip(1) { + if i <= 4 { + assert_eq!(out.placed_by[i], Some(Placement::Float), "point {i}"); + let want = p.pos + v(if p.pos.x > 10.0 { 3.0 } else { -3.0 }, 0.0); + assert!(out.pos[i].unwrap().dist(want) < 1e-9, "point {i}: {:?} vs {want:?}", out.pos[i]); + } else { + assert_eq!(out.placed_by[i], None, "point {i}"); + } + } + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 100 && rep.iterations == 0, "{rep:?}"); +} + +/// Readings of one pair that disagree are left to LM, whatever the order of the constraints. +#[test] +fn conflicting_readings_of_one_pair_do_not_depend_on_order() { + let (mut sk, [.., p, q, _, _]) = bracket_chain(); + sk.add_constraint(ConstraintKind::Distance { p, q, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p, q, value: 30.0 }, None).unwrap(); + let forward = seed_of(&sk); + sk.constraints.reverse(); + assert_eq!(seed_of(&sk), forward); + // Neither end is seeded by a cluster, nor floated with one (P with the triangle O1 P R). + for x in [p, q] { + assert!(!matches!(forward.placed_by[x], Some(Placement::Cluster | Placement::Float)), "point {x}: {forward:?}"); + } +} + +/// A cluster placement must hold every relation of the point, as a propagated one does. +#[test] +fn inconsistent_offsets_are_not_placed() { + // B: 30 right of A, 40 above it, and 60 from it (50 would hold). C hangs off A and B. + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(31.0, 39.0), None).unwrap(); + let c = sk.add_point(v(60.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceX { p: a, q: b, value: 30.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceY { p: a, q: b, value: 40.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: b, value: 60.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 60.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 50.0 }, None).unwrap(); + let out = seed_of(&sk); + assert_eq!((out.placed_by[b], out.placed_by[c]), (None, None), "{out:?}"); +} + +/// A given point is a constant for the solve: never placed, even when out of range. +#[test] +fn given_points_are_never_placed() { + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(40.0, 50.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + let g = sk.points.len(); + sk.points.push(SPoint { id: "far".into(), pos: v(2e9, 0.0), fixed: true, link: None }); + let d = sk.add_point(v(20.0, 30.0), None).unwrap(); + let push = |sk: &mut Sketch, kind: ConstraintKind| { + sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None }) + }; + // Level with A, under B: the row and the column cross at (40, 0), no branch to pick. + push(&mut sk, ConstraintKind::HorizontalPoints { p: a, q: g }); + push(&mut sk, ConstraintKind::VerticalPoints { p: b, q: g }); + push(&mut sk, ConstraintKind::Distance { p: g, q: d, value: 30.0 }); + push(&mut sk, ConstraintKind::Distance { p: a, q: d, value: 36.0 }); + let mask = given(&sk); + assert!(mask[g]); + let out = seed(&sk, &mask, None, &points_on_curves(&sk), &mut Budget::full()); + assert_eq!((out.pos[g], out.placed_by[g]), (None, None), "{out:?}"); + assert_eq!(out.placed_by[d], None, "nothing is built on it: {out:?}"); +} + +/// A component the seed did not move is solved once, also when it fails: the plain-start +/// retry only runs where the seed moved the start. +#[test] +fn an_unseeded_failing_component_is_not_solved_twice() { + // A triangle the seed places, and two free points held 10 and 20 apart. + let mut sk = Sketch::new(); + let a = sk.add_point(v(0.0, 0.0), None).unwrap(); + let b = sk.add_point(v(40.0, 0.0), None).unwrap(); + let c = sk.add_point(v(21.0, 14.0), None).unwrap(); + for p in [a, b] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 25.0 }, None).unwrap(); + let pair = |sk: &mut Sketch| { + let p = sk.add_point(v(100.0, 0.0), None).unwrap(); + let q = sk.add_point(v(130.0, 7.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p, q, value: 20.0 }, None).unwrap(); + }; + pair(&mut sk); + let mut alone = Sketch::new(); + pair(&mut alone); + let pair_alone = seeded(false, || solve(&mut alone)); + let with = seeded(true, || solve(&mut sk)); + assert!(!with.ok() && !pair_alone.ok()); + assert!(pair_alone.iterations > 0); + assert_eq!(with.iterations, pair_alone.iterations, "the triangle takes none, the pair is solved once"); +} + +// --------------------------------------------------------------------------------------------- +// Floating clusters: rigid clusters that are free or pinned at a single placed point. + +/// Distances between pairs of points, each its current length times `scale`. +fn dimension(sk: &mut Sketch, pairs: &[(usize, usize, f64)]) { + for &(p, q, scale) in pairs { + let value = sk.points[p].pos.dist(sk.points[q].pos) * scale; + sk.add_constraint(ConstraintKind::Distance { p, q, value }, None).unwrap(); + } +} + +fn points(sk: &mut Sketch, at: &[Vec2]) -> Vec { + at.iter().map(|p| sk.add_point(*p, None).unwrap()).collect() +} + +/// A floated pose is a guess at free motion: it seeds the solver but nothing is built on it. +/// Two triangles pinned at fixed P and Q, both edited, joined by two links (no freedom left): +/// B is not built on A's guess (and then rejected against it), it is floated too. +#[test] +fn a_floated_pose_anchors_nothing() { + let mut sk = Sketch::new(); + let a = points(&mut sk, &[v(0.0, 0.0), v(10.0, 5.0), v(5.0, 12.0)]); + let b = points(&mut sk, &[v(40.0, 0.0), v(30.0, 5.0), v(35.0, 12.0)]); + for p in [a[0], b[0]] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + for t in [&a, &b] { + dimension(&mut sk, &[(t[0], t[1], 1.0), (t[1], t[2], 1.1), (t[2], t[0], 1.0)]); + } + dimension(&mut sk, &[(a[1], b[1], 1.0), (a[2], b[2], 1.0)]); + let out = seed_of(&sk); + for x in [a[1], a[2], b[1], b[2]] { + assert_eq!(out.placed_by[x], Some(Placement::Float), "point {x}: {out:?}"); + } + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 0, "{rep:?}"); +} + +/// A float is pinned only at a point the seed accepted: the bracket's P is constructed by the +/// decomposition but breaks a midpoint the decomposition does not combine with it, so it is not +/// accepted, and the edited triangle hanging off P is not floated about the rejected position. +#[test] +fn floats_are_pinned_only_at_accepted_points() { + let truth = [v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)]; + let mut sk = Sketch::new(); + let ids = points(&mut sk, &truth); + let [o1, o2, p, q, r] = [ids[0], ids[1], ids[2], ids[3], ids[4]]; + for x in [o1, o2] { + sk.add_constraint(ConstraintKind::Fix { p: x }, None).unwrap(); + } + dimension(&mut sk, &[(p, q, 1.0), (q, r, 1.0), (p, r, 1.0), (o1, p, 1.0), (o1, r, 1.0), (o2, q, 1.0)]); + let qr = sk.add_line_pts(q, r, None).unwrap(); + sk.add_constraint(ConstraintKind::Midpoint { p, l: qr }, None).unwrap(); + let t = points(&mut sk, &[v(5.0, 40.0), v(0.0, 33.0)]); + dimension(&mut sk, &[(p, t[0], 1.0), (t[0], t[1], 1.2), (t[1], p, 1.0)]); + let out = seed_of(&sk); + assert_eq!(out.placed_by[p], None, "{out:?}"); + for x in t { + assert_eq!(out.placed_by[x], None, "point {x}: {out:?}"); + } +} + +/// An edit to a floating triangle read only through a tie (a point's distance from a line) +/// still floats it. +#[test] +fn a_tie_that_misses_floats_its_cluster() { + let mut sk = Sketch::new(); + let [a, b, c] = [v(0.0, 0.0), v(20.0, 0.0), v(10.0, 8.0)].map(|p| sk.add_point(p, None).unwrap()); + let l = sk.add_line_pts(a, b, None).unwrap(); + dimension(&mut sk, &[(a, b, 1.0), (b, c, 1.0)]); + sk.add_constraint(ConstraintKind::PointLineDistance { p: c, l, value: 10.0 }, None).unwrap(); + let out = seed_of(&sk); + for x in [a, b, c] { + assert_eq!(out.placed_by[x], Some(Placement::Float), "point {x}: {out:?}"); + } + let rep = solve(&mut sk); + assert!(rep.ok() && rep.dof == 3 && rep.iterations == 0, "{rep:?}"); +} + +/// A drag step moves only held points: a floating part it does not reach (here one that can't +/// be solved) builds no graph on any frame. +#[test] +fn a_drag_builds_no_graph_for_a_floating_part_it_does_not_reach() { + let mut sk = Sketch::new(); + let t = points(&mut sk, &[v(100.0, 0.0), v(101.0, 0.0), v(100.5, 0.5)]); + for (p, q, value) in [(t[0], t[1], 1.0), (t[1], t[2], 1.0), (t[2], t[0], 5.0)] { + sk.add_constraint(ConstraintKind::Distance { p, q, value }, None).unwrap(); + } + let o = sk.add_point(v(0.0, 0.0), None).unwrap(); + let d = sk.add_point(v(10.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: o }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: o, q: d, value: 10.0 }, None).unwrap(); + let builds = crate::decompose::builds(); + for k in 1..=8 { + drag(&mut sk, d, Vec2::from_angle(k as f64 * 0.1) * 10.0, true); + } + assert_eq!(crate::decompose::builds() - builds, 0); +} + +/// Solved floating parts are where their reference pose puts them: an edit elsewhere floats +/// none of them, and they cost the decomposition little more than its graph. +#[test] +fn solved_floating_parts_are_not_floated() { + let mut sk = Sketch::new(); + let rect = |sk: &mut Sketch, o: Vec2, width: f64| { + let ps = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)].map(|d| sk.add_point(o + d, None).unwrap()); + for i in 0..4 { + let l = sk.add_line_pts(ps[i], ps[(i + 1) % 4], None).unwrap(); + let kind = if i % 2 == 0 { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }; + sk.add_constraint(kind, None).unwrap(); + sk.add_constraint(ConstraintKind::Length { l, value: if i % 2 == 0 { width } else { 10.0 } }, None).unwrap(); + } + ps + }; + for k in 0..1000 { + rect(&mut sk, v((k % 40) as f64 * 30.0, 100.0 + (k / 40) as f64 * 20.0), 20.0); + } + let floating = sk.points.len(); + // The canonical bracket (O1 O2 fixed, rigid P Q R; no propagation order), edited. + let o = v(0.0, -100.0); + let ids = points(&mut sk, &[v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)].map(|p| o + p)); + for x in &ids[..2] { + sk.add_constraint(ConstraintKind::Fix { p: *x }, None).unwrap(); + } + dimension( + &mut sk, + &[(ids[2], ids[3], 1.1), (ids[3], ids[4], 1.0), (ids[2], ids[4], 1.0), (ids[0], ids[2], 1.0), (ids[0], ids[4], 1.0), (ids[1], ids[3], 1.0)], + ); + let out = seed_of(&sk); + let work = crate::decompose::last_work(); + for x in &ids[2..] { + assert_eq!(out.placed_by[*x], Some(Placement::Cluster), "point {x}"); + } + assert!(out.placed_by[1..floating].iter().all(Option::is_none)); + assert!(work < 1_000_000, "{work}"); +} + +#[test] +fn an_arc_follows_its_constructed_centre() { + // An arc whose centre is put on the origin follows it: the seed places the centre, and the + // ends, which nothing else constrains, start carried along instead of left behind (from + // there LM would turn them; reference test 20-slot-constrained). + let mut sk = Sketch::new(); + let arc = sk.add_arc(v(1.0, 1.0), v(1.0, 7.0), v(1.0, -5.0), [None, None, None], None).unwrap(); + let CurveKind::Arc { c, a, b } = sk.curves[arc].kind else { panic!() }; + sk.add_constraint(ConstraintKind::Coincident { p: c, q: 0 }, None).unwrap(); + assert!(solve_holding(&mut sk, &Hold { points: vec![0], radii: vec![] }).ok()); + for (p, want) in [(c, v(0.0, 0.0)), (a, v(0.0, 6.0)), (b, v(0.0, -6.0))] { + assert!(sk.points[p].pos.dist(want) < 1e-9, "{:?} vs {want:?}", sk.points[p].pos); + } +} diff --git a/crates/sketch/src/continuation_tests.rs b/crates/sketch/src/continuation_tests.rs new file mode 100644 index 00000000..d1e25f16 --- /dev/null +++ b/crates/sketch/src/continuation_tests.rs @@ -0,0 +1,343 @@ +//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two +//! triangles joined by three links, left to the iteration by the construction) under random +//! dimension edits from its previous solved state, with every failure that remains proven +//! infeasible; and the solve without continuation when nothing it could help with changed. +use solvecraft_geom::Vec2; + +use crate::construct::switch; +use crate::model::{ConstraintKind, Sketch}; +use crate::solver::{DrivingValues, solve, solve_after_value_edit}; + +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + self.next() * (hi - lo) + } +} + +/// The linkage's solved layout: base triangle 0 1 2 (0 fixed, 0–1 horizontal), top triangle +/// 3 4 5, links 0–3, 1–4, 2–5. +const LAYOUT: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)]; +const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)]; + +/// The linkage in its solved layout with every distance edited by `lo..hi`×, and the values +/// the layout was solved for (per constraint). With `lines`, each triangle side is a line of +/// its own (end points coincident with the corners) with a length dimension. +fn linkage(rng: &mut Lcg, lo: f64, hi: f64, lines: bool) -> (Sketch, DrivingValues) { + let mut sk = Sketch::new(); + let ids: Vec = LAYOUT.iter().map(|p| sk.add_point(Vec2::new(p.0, p.1), None).unwrap()).collect(); + sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap(); + let l = sk.add_line_pts(ids[0], ids[1], None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + for (k, (a, b)) in PAIRS.into_iter().enumerate() { + let (pa, pb) = (sk.points[ids[a]].pos, sk.points[ids[b]].pos); + if lines && k < 6 { + let l = sk.add_line(pa, pb, None, None, None).unwrap(); + let (s, e) = sk.curves[l].kind.ends().unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: s, q: ids[a] }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: e, q: ids[b] }, None).unwrap(); + sk.add_constraint(ConstraintKind::Length { l, value: pa.dist(pb) }, None).unwrap(); + } else { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: pa.dist(pb) }, None).unwrap(); + } + } + let old = sk.driving_values(); + for c in &mut sk.constraints { + if let Some(v) = c.kind.value() { + c.kind.set_value(v * rng.range(lo, hi)); + } + } + (sk, old) +} + +/// The nine distances of a linkage sketch, in `PAIRS` order. +fn distances(sk: &Sketch) -> Vec { + sk.constraints.iter().filter_map(|c| c.kind.value()).collect() +} + +/// Turn of both triangles (layout point `i` is sketch point `i + 1`, after the origin). +fn turns(sk: &Sketch) -> [bool; 2] { + let p = |i: usize| sk.points[i + 1].pos; + [(p(1) - p(0)).cross(p(2) - p(0)) > 0.0, (p(4) - p(3)).cross(p(5) - p(3)) > 0.0] +} + +/// Both intersections of two circles; `None` when they miss by more than rounding. +fn circles(c0: Vec2, r0: f64, c1: Vec2, r1: f64) -> Option<[Vec2; 2]> { + let d = c0.dist(c1); + let a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d); + let h2 = r0 * r0 - a * a; + if h2 < -1e-9 * r0 * r0 { + return None; + } + let u = (c1 - c0) / d; + let m = c0 + u * a; + let h = h2.max(0.0).sqrt(); + Some([m + u.perp() * h, m - u.perp() * h]) +} + +/// Whether the linkage with distances `d` (`PAIRS` order) has a real solution, decided by +/// branch and bound. With the base triangle placed (one of its four mirror images) and the +/// top triangle's shape fixed (one of two), a solution is an angle φ of link 0–3 and a turn θ +/// of the top triangle where the links 1–4 and 2–5 have their lengths. Over a cell of +/// half-width w in (φ, θ) the points move at most (d03 + top radius)·w, so a cell whose centre +/// misses both lengths by more than that holds no solution: when every cell is discarded so, +/// none exists. A cell that cannot be discarded down to w ≈ 1e-10 holds one within rounding. +/// `keep`: only solutions with both triangles turning so. `near`: only solutions with every +/// point within the distance of the given layout (discarded the same way). +fn feasible(d: &[f64], keep: Option<[bool; 2]>, near: Option<(&[Vec2], f64)>) -> bool { + let [d01, d12, d20, d34, d45, d53, d03, d14, d25] = d.try_into().unwrap(); + let Some(tops) = circles(Vec2::ZERO, d53, Vec2::new(d34, 0.0), d45) else { return false }; + let q4 = Vec2::new(d34, 0.0); + let lip = d03 + d34.max(d53); + let far = |i: usize, at: Vec2, slack: f64| near.is_some_and(|(layout, r)| layout[i].dist(at) > r + slack); + for s1 in [1.0, -1.0] { + let p1 = Vec2::new(s1 * d01, 0.0); + let Some(p2s) = circles(Vec2::ZERO, d20, p1, d12) else { return false }; + for p2 in p2s.into_iter().filter(|p2| keep.is_none_or(|k| (p1.cross(*p2) > 0.0) == k[0])) { + if far(1, p1, 0.0) || far(2, p2, 0.0) { + continue; + } + for q5 in tops.into_iter().filter(|q5| keep.is_none_or(|k| (q4.cross(*q5) > 0.0) == k[1])) { + let miss = |phi: f64, theta: f64, w: f64| { + let rot = |q: Vec2| Vec2::new(q.x * theta.cos() - q.y * theta.sin(), q.x * theta.sin() + q.y * theta.cos()); + let p3 = Vec2::from_angle(phi) * d03; + let (p4, p5) = (p3 + rot(q4), p3 + rot(q5)); + if far(3, p3, lip * w) || far(4, p4, lip * w) || far(5, p5, lip * w) { + return f64::INFINITY; + } + (p1.dist(p4) - d14).abs().max((p2.dist(p5) - d25).abs()) + }; + let n = 64; + let w0 = std::f64::consts::PI / n as f64; + let mut stack: Vec<(f64, f64, f64)> = Vec::new(); + for i in 0..n { + for j in 0..n { + stack.push(((2 * i + 1) as f64 * w0, (2 * j + 1) as f64 * w0, w0)); + } + } + while let Some((phi, theta, w)) = stack.pop() { + if miss(phi, theta, w) > lip * w * (1.0 + 1e-9) + 1e-12 { + continue; + } + if w < 1e-10 { + return true; + } + let h = w / 2.0; + for (a, b) in [(-h, -h), (-h, h), (h, -h), (h, h)] { + stack.push((phi + a, theta + b, h)); + } + } + } + } + } + false +} + +/// The edit's distances `t` of the way from old to new. +fn along(old: &[f64], new: &[f64], t: f64) -> Vec { + old.iter().zip(new).map(|(o, n)| o + t * (n - o)).collect() +} + +/// Outcome counts over the family. +#[derive(Debug, Default)] +struct Count { + one_jump_failed: usize, + one_jump_folded: usize, + /// Turned a triangle over (named in the report, for a value edit to refuse). + refused: usize, + /// Failed otherwise. + failed: usize, + /// Of the refused and failed: the new values have no solution. + infeasible: usize, + /// The new values are solved only with a triangle mirrored. + only_mirrored: usize, + /// The sketch's own solution ends on the straight value path: followed in 1/4096 steps + /// it fails or folds at some t < 1, and no orientation-keeping solution for the values at + /// t lies within 1 mm of where it was one step before (a fold of the path, or no solution). + branch_ends: usize, + /// Refused or failed cases none of the above explains: old and new distances. + unexplained: Vec<(Vec, Vec)>, +} + +/// The linkage's distances set to `d`. +fn set_distances(sk: &mut Sketch, d: &[f64]) { + for (c, v) in sk.constraints.iter_mut().filter(|c| c.kind.value().is_some()).zip(d) { + c.kind.set_value(*v); + } +} + +/// The value path from `old` to `new` in 1/4096 steps, each solved by the iteration alone +/// from the previous solution: where it fails or folds a triangle (t and the layout one step +/// before), or `None` when it reaches the new values. +fn fine_path(sk: &Sketch, old: &[f64], new: &[f64]) -> Option<(f64, Vec)> { + let mut s = sk.clone(); + for (p, xy) in s.points.iter_mut().skip(1).zip(LAYOUT) { + p.pos = Vec2::new(xy.0, xy.1); + } + let before = turns(&s); + let n = 4096; + for k in 1..=n { + let t = k as f64 / n as f64; + let last: Vec = s.points.iter().skip(1).map(|p| p.pos).collect(); + set_distances(&mut s, &along(old, new, t)); + let rep = switch::without_differential(|| switch::without_seed(|| solve(&mut s))); + if !rep.ok() || turns(&s) != before { + return Some((t, last)); + } + } + None +} + +/// `n` edits of the family: the one-jump solve (LM from the layout) against +/// `solve_after_value_edit`, which must solve keeping both triangles' turn or refuse with the +/// layout kept; every refusal and failure is explained (see [`Count`]). +fn run_family(seed: u64, n: usize, lo: f64, hi: f64, lines: bool) -> Count { + let mut rng = Lcg(seed); + let mut count = Count::default(); + for _ in 0..n { + let (sk, old) = linkage(&mut rng, lo, hi, lines); + let before = turns(&sk); + let (mut jump, mut cont) = (sk.clone(), sk.clone()); + let (a, b) = switch::without_differential(|| (solve(&mut jump), solve_after_value_edit(&mut cont, &old))); + count.one_jump_failed += usize::from(!a.ok()); + count.one_jump_folded += usize::from(a.ok() && turns(&jump) != before); + if a.ok() && turns(&jump) == before { + assert_eq!(jump.points, cont.points, "a one-jump solve that keeps its triangles is the result"); + } + if b.keeps_orientation() { + assert_eq!(turns(&cont), before, "an accepted edit keeps every triangle's turn"); + continue; + } + if b.folded.is_empty() { + count.failed += 1; + assert_eq!(cont.points, sk.points, "a failed solve keeps the layout"); + } else { + // Solved, turned over: kept as the best result, named for a command to refuse. + count.refused += 1; + assert!(b.ok() && turns(&cont) != before, "{b:?}"); + let dims: Vec<&str> = sk.constraints.iter().filter(|c| c.kind.value().is_some()).map(|c| c.id.as_str()).collect(); + assert!(b.folded.iter().all(|id| dims.contains(&id.as_str()) && b.failing.contains(id)), "{b:?}"); + } + let new = distances(&sk); + let old: Vec = sk.constraints.iter().filter_map(|c| old.get(&c.id).copied()).collect(); + if !feasible(&new, None, None) { + count.infeasible += 1; + } else if !feasible(&new, Some(before), None) { + count.only_mirrored += 1; + } else if let Some((t, last)) = fine_path(&sk, &old, &new) + && !feasible(&along(&old, &new, t), Some(before), Some((&last, 1.0))) + { + count.branch_ends += 1; + } else { + count.unexplained.push((old, new)); + } + } + count +} + +fn report(name: &str, c: &Count) { + eprintln!( + "{name}: one jump failed {} folded {}; continuation refused {} failed {} (infeasible {}, only mirrored {}, branch ends {}, unexplained {})", + c.one_jump_failed, + c.one_jump_folded, + c.refused, + c.failed, + c.infeasible, + c.only_mirrored, + c.branch_ends, + c.unexplained.len() + ); + for u in c.unexplained.iter().take(3) { + eprintln!(" {u:?}"); + } +} + +#[test] +fn feasibility_check_agrees_with_the_layout() { + let d: Vec = PAIRS.iter().map(|&(a, b)| Vec2::new(LAYOUT[a].0, LAYOUT[a].1).dist(Vec2::new(LAYOUT[b].0, LAYOUT[b].1))).collect(); + assert!(feasible(&d, None, None)); + let mut long = d.clone(); + long[7] = 500.0; + assert!(!feasible(&long, None, None)); + let mut flat = d; + flat[0] = flat[1] + flat[2] + 1.0; + assert!(!feasible(&flat, None, None)); +} + +#[test] +fn linkage_edits_are_solved_by_continuation_or_refused_with_reason() { + // The first 40 edits of the two families of `linkage_family_counter`, with every refusal + // and failure explained. + let c = run_family(2026, 40, 0.8, 1.25, false); + report("linkage 0.8–1.25× (40)", &c); + assert!(c.unexplained.is_empty() && c.branch_ends > 0 && c.failed + c.refused < c.one_jump_failed + c.one_jump_folded, "{c:?}"); + let c = run_family(7, 40, 0.5, 2.5, true); + report("linkage 0.5–2.5× lines (40)", &c); + assert!(c.unexplained.is_empty() && c.refused > 0, "{c:?}"); +} + +/// Measurement, not a gate: the two families, 500 edits each, every refusal and failure +/// explained (run with `--ignored --nocapture`). +#[test] +#[ignore] +fn linkage_family_counter() { + for (seed, lo, hi, lines) in [(2026, 0.8, 1.25, false), (7, 0.5, 2.5, true)] { + let c = run_family(seed, 500, lo, hi, lines); + report(&format!("linkage {lo}–{hi}× lines {lines} (500)"), &c); + assert!(c.unexplained.is_empty(), "{c:?}"); + } +} + +#[test] +fn edits_from_an_unsolved_state_are_not_continued() { + // Old values the layout is not solved for: no solved state to step from, nor orientations + // to keep; the one-jump result stands. + let mut rng = Lcg(2026); + let mut tried = 0; + for _ in 0..200 { + let (sk, old) = linkage(&mut rng, 0.8, 1.25, false); + let wrong: DrivingValues = old.iter().map(|(k, v)| (k.clone(), v * 1.1)).collect(); + let (mut a, mut b) = (sk.clone(), sk.clone()); + let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &wrong)); + if ra.ok() { + continue; + } + tried += 1; + assert_eq!(rb.continuations, 0, "{rb:?}"); + assert!(rb.folded.is_empty()); + assert_eq!(a.points, b.points); + } + assert!(tried > 0); +} + +#[test] +fn unchanged_values_solve_as_without_old_values() { + let mut rng = Lcg(1); + for _ in 0..20 { + let (sk, _) = linkage(&mut rng, 0.8, 1.25, true); + let current = sk.driving_values(); + let (mut a, mut b) = (sk.clone(), sk.clone()); + let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, ¤t)); + assert_eq!((ra.status, ra.iterations, ra.dof), (rb.status, rb.iterations, rb.dof)); + assert_eq!(rb.continuations, 0); + assert_eq!(a.points, b.points); + } +} + +#[test] +fn hostile_old_values_never_panic() { + let mut rng = Lcg(3); + let (sk, old) = linkage(&mut rng, 0.5, 2.5, false); + let all = |v: f64| -> DrivingValues { old.keys().map(|k| (k.clone(), v)).collect() }; + let unknown: DrivingValues = (0..20).map(|i| (format!("nope{i}"), 1.0)).collect(); + for bad in [DrivingValues::new(), all(f64::NAN), all(f64::INFINITY), all(-1e300), all(0.0), unknown, old.clone()] { + let mut s = sk.clone(); + let rep = solve_after_value_edit(&mut s, &bad); + assert!(rep.max_residual.is_finite()); + assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite())); + } +} diff --git a/crates/sketch/src/decompose.rs b/crates/sketch/src/decompose.rs new file mode 100644 index 00000000..74472933 --- /dev/null +++ b/crates/sketch/src/decompose.rs @@ -0,0 +1,1053 @@ +//! Cluster decomposition: places points that are rigid relative to known geometry but that no +//! point-by-point construction reaches. +//! +//! Owen (1991, §3) splits a well-constrained constraint graph at articulation pairs into +//! triangles whose sides are original dimensions or *virtual bonds*, solves the triangles with +//! square roots and reassembles the pieces (§3.3.2). Fudos and Hoffmann (1997) run the same +//! reduction bottom-up, and that is the form implemented here (Hoffmann 2005 gives the +//! summary): +//! +//! - a **cluster** is a set of points whose relative positions are known, stored in the +//! cluster's own frame. Every dimension between two points starts as a two-point cluster; +//! the known geometry is the *ground* cluster, in world coordinates; +//! - three clusters that pairwise share one point know the three distances between the +//! shared points (Owen's virtual bonds). The triangle is constructed in one cluster's frame +//! (circle ∩ circle) and the other two are moved onto it rigidly: the three become one; +//! - a rigid cluster sharing two points with another is moved onto it; +//! - the ground absorbs everything it reaches, so placing the root cluster is the rigid +//! transform onto the shared known points. +//! +//! Horizontal/vertical relations and DistanceX/Y are not invariant under rotation. Two of them +//! between the same pair of points (or one with a distance) fix the pair's offset vector: an +//! *oriented* cluster, known up to a translation. Two oriented clusters (the ground counts as +//! one) sharing a point merge by translation. A rigid cluster holding both ends of a single +//! horizontal/vertical relation turns until it holds and becomes oriented; one pinned to an +//! oriented cluster at a single point turns about the pin until a relation between the two +//! holds (circle ∩ axis, the hinge step). +//! +//! Branches follow Owen's rule (§2.4.3, §5): every constructed triangle keeps the orientation +//! (clockwise or counter-clockwise) its three points have in `reference`, the geometry before +//! the solve (or before a drag). A cluster is never reflected when it is moved. +//! +//! The relations come from [`crate::loci`]'s reading, as for propagation: those between two +//! points build the starting clusters, and a point that is not in a cluster but whose +//! relations and ties to the cluster's points (a point on a line through two of them, a +//! midpoint, a mirrored point, a tangent circle's centre, a direction derived from a line of +//! the cluster…) cross at one place joins it (Owen's single-point merge), placed by the same +//! [`crate::loci::place`] as propagation, in the cluster's frame. Relations fixed in the +//! sketch's frame (horizontal, vertical, line angles) count only in a ground or oriented +//! cluster. +//! +//! A rigid cluster that never reaches the ground (no known point, or a single one it can still +//! turn about) has no pose the constraints decide. Once nothing more is placed, +//! [`Decomposition::floats`] places such a cluster where its reference pose puts it, if that +//! moves it at all (its pin moved, or one of its relations misses at the reference): free, the +//! rotation and translation that fit its points to their reference positions best in the +//! least-squares sense; pinned, its reference pose about the pin moved onto the pin (a large +//! move of the pin carries it along unturned); an oriented cluster by translation only. It is +//! never reflected. The pose is a guess at free motion: the caller seeds the solver with it but +//! builds nothing on it, and the solver keeps every degree of freedom. +//! +//! What remains unplaced is left to the numerical solver: triconnected remainders, which no +//! sequence of square roots solves (Owen §3.3.1 step 4), and anything held by constraints this +//! module does not read. The output only seeds the solver; it verifies every constraint and +//! its rank analysis decides the degrees of freedom. Every intersection goes through [`crate::loci`]: a near-tangent one pins nothing +//! down well and a branch the reference does not decide is unknown, so neither is merged +//! (the solver starts those points where they are). + +use std::collections::btree_map::Entry; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use solvecraft_geom::Vec2; + +use crate::loci::{Locus, Reading, Rel, Span, View, crossing, mag, place, side}; +use crate::union_find::{find, union}; + +/// More starting clusters than this (one per dimensioned point pair) and the sketch is left +/// to the numerical solver unchanged. +const MAX_CLUSTERS: usize = 20_000; +/// Bound on the membership scans of the merge search, so a hostile sketch can't stall it +/// (over the life of one graph, whatever is added to it): about 0.3 s. +pub(crate) const MAX_WORK: usize = 20_000_000; +/// The bound for all the solves of one live drag frame together: about 16 ms. What a solve's +/// decomposition leaves unplaced once it is spent starts where it is, for LM, so that solve keeps +/// its branch only by continuation: a drag that runs out takes short steps for the rest of the +/// frame (see [`Budget::exhausted`]). +pub(crate) const FRAME_WORK: usize = 1_000_000; + +/// Work the seed's cluster decomposition may do, shared by the solves it is passed to (the held +/// solves of one drag frame): each decomposition is bounded by what is left, and charges what it +/// did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Budget { + left: usize, + spent: usize, + cut: bool, +} + +impl Budget { + /// A budget of `work` units (a unit is one membership scan of the merge search). + pub fn new(work: usize) -> Budget { + Budget { left: work, spent: 0, cut: false } + } + /// The budget of one solve: about 0.3 s, so a hostile sketch can't stall it. + pub fn full() -> Budget { + Budget::new(MAX_WORK) + } + /// The budget of one live drag frame: about 16 ms. + pub fn frame() -> Budget { + Budget::new(FRAME_WORK) + } + /// Some decomposition stopped because the budget ran out: what it would have placed was + /// left to LM, from where it was. + pub fn exhausted(&self) -> bool { + self.cut + } + /// The work charged so far (it may pass the budget by one scan). + pub fn spent(&self) -> usize { + self.spent + } + pub(crate) fn left(&self) -> usize { + self.left + } + /// Charge a decomposition's `work`; `cut` when it stopped at the budget. + pub(crate) fn charge(&mut self, work: usize, cut: bool) { + self.left = self.left.saturating_sub(work); + self.spent = self.spent.saturating_add(work); + self.cut |= cut; + } +} +/// Shorter than this (mm), a pair of points gives no direction to align on. +const DEGENERATE: f64 = 1e-9; + +/// The constraint graph of a sketch reduced by cluster merging, relative to the known points. +/// More points can become known later (placed by propagation); the graph is extended and +/// reduced further rather than rebuilt. +pub(crate) struct Decomposition<'a> { + /// `None` when nothing can be placed or the sketch is too large. + graph: Option>, +} + +impl<'a> Decomposition<'a> { + /// Reduce the graph of the reading's relations relative to the known points (there may be + /// none), unless it can place nothing (see [`may_place`]): then no graph is built. + /// + /// `known[i]` is the position of point `i` when it is already fixed (fixed, held or placed + /// by propagation; a missing or non-finite entry is unknown). The reading's reference + /// layout picks the branch of every construction (where it has no position for a point, + /// every branch it would decide is left to the solver). `edits`: the solve applies edits + /// (no drag step): a floating part whose relations miss at the reference may float. + /// + /// `limit` bounds the merge search's work over the decomposition's life (what is left of + /// the solve's [`Budget`]). + pub(crate) fn new(reading: &'a Reading, known: &[Option], edits: bool, limit: usize) -> Self { + let known_at = |i: usize| known.get(i).copied().flatten().filter(|p| p.is_finite()); + if !may_place(reading, &known_at, edits) { + return Decomposition { graph: None }; + } + let mut graph = Graph::build(reading, &known_at, limit); + if let Some(g) = &mut graph { + g.edits = edits; + g.reduce(); + } + Decomposition { graph } + } + + /// A graph was built: the decomposition may place points. + pub(crate) fn built(&self) -> bool { + self.graph.is_some() + } + + /// The points are now known at their positions: merge what that lets merge. + pub(crate) fn know(&mut self, points: &[(usize, Vec2)]) { + let Some(g) = &mut self.graph else { return }; + for (i, at) in points.iter().filter(|(_, at)| at.is_finite()) { + g.know(*i, *at); + } + g.reduce(); + } + + /// Where the ground cluster has point `i` (known points included, at their known + /// positions); None when the decomposition did not reach it or built no graph. + pub(crate) fn pos(&mut self, i: usize) -> Option { + let g = self.graph.as_mut()?; + let node = g.node(i); + g.cluster(g.ground?)?.pts.get(&node).copied().filter(|p| p.is_finite()) + } + + /// The work the merge search did, and whether it stopped at its limit. + pub(crate) fn work(&self) -> (usize, bool) { + self.graph.as_ref().map_or((0, false), |g| (g.work, g.work > g.limit)) + } + + /// The merge search's budget is spent: nothing more will merge. + pub(crate) fn exhausted(&self) -> bool { + self.graph.as_ref().is_none_or(|g| g.work > g.limit) + } + + /// The rigid clusters that are free or pinned at a single anchored point, each placed by its + /// reference pose (see the module doc) as its points but the pin with their positions; + /// pinned ones first, larger ones first. Only those the pose moves: pinned at a point that + /// moved from its reference, or with a relation among its points that misses at the + /// reference. A cluster holding a point of a pair whose readings disagree is left to the + /// solver. `anchor(i)`: where the seed placed point `i`, if it builds on it (pins come only + /// from there). + pub(crate) fn floats(&mut self, anchor: &dyn Fn(usize) -> Option) -> Vec> { + self.graph.as_mut().map_or_else(Vec::new, |g| g.floats(anchor)) + } +} + +/// Can a decomposition place anything? A point it places is rigid relative to known points or +/// to the other points of a cluster of three or more (or of an oriented pair), so it has two +/// relations (a coincidence counts twice, a tie relates each of its points to each other one) +/// to known points and to points that are themselves rigid. Peeling unknown points with fewer, +/// until none is left, keeps every point a decomposition could place; it places something +/// only if one is kept next to a known point, or (`edits`) a kept point misses a relation or +/// tie at the reference, so a floating cluster may float. A drag step moves only held points: +/// a floating part it does not reach is left as it is. Linear in the relations, so it is cheap +/// next to building the graph; this is where most drag frames of an under-constrained sketch +/// stop (chains of dimensioned links peel away entirely). +fn may_place(reading: &Reading, known: &dyn Fn(usize) -> Option, edits: bool) -> bool { + let n = reading.points(); + let weight = |rel: Rel| if rel.joins() { 2 } else { 1 }; + let mut adj: Vec> = vec![Vec::new(); n]; + let mut degree = vec![0usize; n]; + let ties = reading.tie_point_lists().flat_map(|pts| pts.iter().flat_map(move |p| pts.iter().filter(move |q| *q > p).map(move |q| (*p, *q, 1)))); + for (p, q, w) in reading.rels.iter().map(|&(p, q, rel)| (p, q, weight(rel))).chain(ties) { + for (a, b) in [(p, q), (q, p)] { + if let Some(list) = adj.get_mut(a) { + list.push((b, w)); + } + if let Some(d) = degree.get_mut(a) { + *d += w; + } + } + } + let mut alive: Vec = (0..n).map(|i| known(i).is_none()).collect(); + let mut peel: Vec = (0..n).filter(|i| alive.get(*i).copied().unwrap_or(false) && degree.get(*i).is_some_and(|d| *d < 2)).collect(); + while let Some(i) = peel.pop() { + if let Some(a) = alive.get_mut(i) { + *a = false; + } + for &(o, w) in adj.get(i).into_iter().flatten() { + if alive.get(o).copied().unwrap_or(false) + && let Some(d) = degree.get_mut(o) + { + let was = *d; + *d = d.saturating_sub(w); + if was >= 2 && *d < 2 { + peel.push(o); + } + } + } + } + let kept = |i: usize| alive.get(i).copied().unwrap_or(false); + let next_to_known = |i: usize| adj.get(i).into_iter().flatten().any(|(o, _)| known(*o).is_some()); + let before = |i: usize| reading.before(i); + let view = View { at: &before, oriented: true }; + let misses = |i: usize| before(i).is_none_or(|at| !reading.holds(i, at, &view)); + (0..n).any(|i| kept(i) && (next_to_known(i) || (edits && misses(i)))) +} + +#[cfg(test)] +thread_local! { + static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Graphs built on this thread so far (tests count rebuilds with it). +#[cfg(test)] +pub(crate) fn builds() -> usize { + BUILDS.with(std::cell::Cell::get) +} + +/// The work charged to the last decomposition dropped on this thread (0 when it built no +/// graph). +#[cfg(test)] +pub(crate) fn last_work() -> usize { + LAST_WORK.with(std::cell::Cell::get) +} + +#[cfg(test)] +impl Drop for Decomposition<'_> { + fn drop(&mut self) { + let work = self.graph.as_ref().map_or(0, |g| g.work); + LAST_WORK.with(|w| w.set(work)); + } +} + +/// How much of a cluster's placement is still free, least free first: a merge keeps the +/// frame of its least free cluster. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Frame { + /// World coordinates. + Ground, + /// World coordinates up to a translation. + Oriented, + /// World coordinates up to a rotation and a translation. + Rigid, +} + +#[derive(Clone, Debug)] +struct Cluster { + frame: Frame, + /// Node → position in the cluster's frame. + pts: BTreeMap, + /// Node → the point the cluster's constraints are written on there. Coincident points + /// may be apart in the reference; branches are picked from this point's side. + via: BTreeMap, + /// Nodes whose neighbours [`Graph::grow`] has not tried yet. + fresh: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Axis { + X, + Y, +} + +impl Axis { + /// (the coordinate along the axis, the other one). + fn split(self, v: Vec2) -> (f64, f64) { + match self { + Axis::X => (v.x, v.y), + Axis::Y => (v.y, v.x), + } + } + fn join(self, along: f64, other: f64) -> Vec2 { + match self { + Axis::X => Vec2::new(along, other), + Axis::Y => Vec2::new(other, along), + } + } +} + +/// One horizontal/vertical relation alone between two nodes: `axis(hi) − axis(lo) = delta`. +/// `ends` are the points the constraint is written on (on the `lo` and `hi` side), whose +/// reference positions pick branches: coincident points may be apart in the reference. +#[derive(Clone, Copy, Debug)] +struct AxisBond { + lo: usize, + hi: usize, + ends: (usize, usize), + axis: Axis, + delta: f64, +} + +/// The constraints this module reads between one pair of nodes, signed from `lo` to `hi`; +/// `ends` as in [`AxisBond`] (the lowest pair of points written on, so that nothing depends +/// on the order of the constraints). +#[derive(Clone, Copy, Debug)] +struct Bundle { + ends: (usize, usize), + dist: Option, + dx: Option, + dy: Option, +} + +impl Bundle { + /// The agreed (distance, dx, dy); None when readings of one kind disagree, or a distance + /// disagrees with both offsets. + fn agreed(&self) -> Option<(Option, Option, Option)> { + let one = |s: Option| s.map_or(Some(None), |s| s.agreed().map(Some)); + let (dist, dx, dy) = (one(self.dist)?, one(self.dx)?, one(self.dy)?); + if let (Some(len), Some(x), Some(y)) = (dist, dx, dy) + && !Rel::Dist(len).holds(Vec2::ZERO, Vec2::new(x, y), 0.0) + { + return None; + } + Some((dist, dx, dy)) + } +} + +/// Rotation then translation taking `from` to `to`; never a reflection. +#[derive(Clone, Copy, Debug)] +struct Motion { + /// (cos, sin) of the rotation. + rot: Vec2, + from: Vec2, + to: Vec2, +} + +impl Motion { + const IDENTITY: Motion = Motion { rot: Vec2::X, from: Vec2::ZERO, to: Vec2::ZERO }; + + fn translate(from: Vec2, to: Vec2) -> Self { + Motion { rot: Vec2::X, from, to } + } + + /// The motion taking `ua` to `wa` and the direction `ua→ub` onto `wa→wb`. + fn onto(ua: Vec2, ub: Vec2, wa: Vec2, wb: Vec2) -> Option { + let (u, w) = (ub - ua, wb - wa); + if !(u.len() > DEGENERATE && w.len() > DEGENERATE) { + return None; + } + let rot = Vec2::new(u.dot(w), u.cross(w)).normalized()?; + Some(Motion { rot, from: ua, to: wa }) + } + + fn apply(&self, p: Vec2) -> Vec2 { + let d = p - self.from; + self.to + Vec2::new(self.rot.x * d.x - self.rot.y * d.y, self.rot.y * d.x + self.rot.x * d.y) + } +} + +/// (cos, sin) of the rotation turning the vectors `u` onto their `w` best in the least-squares +/// sense (never a reflection); None when it is undecided (they are all zero, or the fit is as +/// good at every angle). +fn best_turn(pairs: impl Iterator) -> Option { + let (mut sum, mut scale) = (Vec2::ZERO, 0.0); + for (u, w) in pairs { + sum += Vec2::new(u.dot(w), u.cross(w)); + scale += u.len() * w.len(); + } + if !(sum.len() > 1e-9 * scale) { + return None; + } + sum.normalized() +} + +/// A merge: the clusters taking part, each with the motion into the merged frame (the first +/// one's positions win where they overlap), and the merged frame. +struct Merge { + frame: Frame, + parts: Vec<(usize, Motion)>, +} + +/// The point at distance `r` from `centre` whose `axis` coordinate is `along` past +/// `centre`'s (circle ∩ axis line), on the side of the axis through `ref_centre` that +/// `ref_point` is on. +fn on_axis(axis: Axis, centre: Vec2, r: f64, along: f64, ref_centre: Option, ref_point: Option) -> Option { + let loci = |c: Vec2| (Locus::Circle { c, r }, Locus::Line { o: c + axis.join(along, 0.0), d: axis.join(0.0, 1.0) }); + crossing(loci(centre), ref_centre.map(loci), ref_point) +} + +/// The constraint graph reduced by cluster merging. +struct Graph<'a> { + /// What the constraints say, and the reference layout (per point, not per node). + reading: &'a Reading, + /// Point → representative node (coincident points share one; final once built). + node_of: Vec, + /// Node → its points. + points_of: Vec>, + /// `None` once merged into another cluster; a merge keeps its base cluster's slot. + clusters: Vec>, + /// Per cluster: waiting in `queue`. + queued: Vec, + /// Per node: in a pair whose readings disagree (left to the solver, never floated). + conflicted: Vec, + /// The solve applies edits (see [`Decomposition::new`]): a cluster whose relations miss at + /// the reference floats; otherwise only one whose pin moved. + edits: bool, + /// Node → clusters containing it. Merged-away clusters are dropped lazily (they stay + /// `None` for good), so a merge touches only the nodes that change cluster. + member: Vec>, + bonds: Vec, + /// Node → axis bonds touching it. + bonds_of: Vec>, + queue: VecDeque, + /// Every scan of the merge search is charged here; past `limit` the search stops. + work: usize, + /// The work budget. + limit: usize, + /// The ground cluster's slot (a merge never moves it: the ground is always the base); + /// always set once built, possibly empty. + ground: Option, +} + +impl<'a> Graph<'a> { + fn build(reading: &'a Reading, known: &dyn Fn(usize) -> Option, limit: usize) -> Option { + #[cfg(test)] + BUILDS.with(|b| b.set(b.get() + 1)); + let n = reading.points(); + let rels = &reading.rels; + // Coincident points, and points at distance zero, are one node: the lowest point of + // its group. + let mut parent: Vec = (0..n).collect(); + for &(p, q, rel) in rels { + if rel.joins() { + let (a, b) = (find(&mut parent, p), find(&mut parent, q)); + union(&mut parent, a.max(b), a.min(b)); + } + } + let node_of: Vec = (0..n).map(|i| find(&mut parent, i)).collect(); + let mut points_of = vec![Vec::new(); n]; + for (i, node) in node_of.iter().enumerate() { + if let Some(list) = points_of.get_mut(*node) { + list.push(i); + } + } + let mut g = Graph { + reading, + node_of, + points_of, + clusters: Vec::new(), + queued: Vec::new(), + conflicted: vec![false; n], + edits: false, + member: vec![Vec::new(); n], + bonds: Vec::new(), + bonds_of: vec![Vec::new(); n], + queue: VecDeque::new(), + work: 0, + limit, + ground: None, + }; + let mut ground = Cluster { frame: Frame::Ground, pts: BTreeMap::new(), via: BTreeMap::new(), fresh: Vec::new() }; + for i in 0..n { + if let Some(p) = known(i) { + let node = g.node(i); + if let Entry::Vacant(slot) = ground.pts.entry(node) { + slot.insert(p); + ground.via.insert(node, i); + } + } + } + + let mut bundles: BTreeMap<(usize, usize), Bundle> = BTreeMap::new(); + for &(p, q, rel) in rels { + let (axis, value) = match rel { + Rel::Same => continue, + Rel::Dist(v) => (None, v), + Rel::SameX => (Some(Axis::X), 0.0), + Rel::SameY => (Some(Axis::Y), 0.0), + Rel::OffsetX(v) => (Some(Axis::X), v), + Rel::OffsetY(v) => (Some(Axis::Y), v), + }; + let (a, b) = (g.node(p), g.node(q)); + if a == b { + continue; + } + let ((lo, lp), (hi, hp)) = if a < b { ((a, p), (b, q)) } else { ((b, q), (a, p)) }; + // Signed offsets take the side the constrained points are on before the solve; a + // nonzero offset whose side is unknown is left to the solver. + let signed = |axis: Axis| { + if value == 0.0 { + return Some(0.0); + } + let (from, to) = (g.at(lp)?, g.at(hp)?); + let (c, _) = axis.split(to - from); + side(c, value, mag(from).max(mag(to))).map(|s| s * value) + }; + let Some(v) = axis.map_or(Some(value), signed) else { continue }; + let bundle = bundles.entry((lo, hi)).or_insert(Bundle { ends: (lp, hp), dist: None, dx: None, dy: None }); + bundle.ends = bundle.ends.min((lp, hp)); + Span::add( + match axis { + None => &mut bundle.dist, + Some(Axis::X) => &mut bundle.dx, + Some(Axis::Y) => &mut bundle.dy, + }, + v, + ); + } + + let mut pairs = Vec::new(); + for ((lo, hi), bundle) in bundles { + // Readings that disagree are left to the solver, which reports the conflict. + let Some((dist, dx, dy)) = bundle.agreed() else { + for node in [lo, hi] { + if let Some(c) = g.conflicted.get_mut(node) { + *c = true; + } + } + continue; + }; + let ends = bundle.ends; + if ground.pts.contains_key(&lo) && ground.pts.contains_key(&hi) { + continue; + } + let dir = g.at(ends.1).zip(g.at(ends.0)).and_then(|(b, a)| (b - a).normalized()); + let offset = match (dx, dy, dist) { + (Some(x), Some(y), _) => Some((Vec2::new(x, y), Frame::Oriented)), + (Some(x), None, Some(len)) => on_axis(Axis::X, Vec2::ZERO, len, x, g.at(ends.0), g.at(ends.1)).map(|v| (v, Frame::Oriented)), + (None, Some(y), Some(len)) => on_axis(Axis::Y, Vec2::ZERO, len, y, g.at(ends.0), g.at(ends.1)).map(|v| (v, Frame::Oriented)), + (None, None, Some(len)) => Some((dir.unwrap_or(Vec2::X) * len, Frame::Rigid)), + (Some(delta), None, None) => { + g.bond(AxisBond { lo, hi, ends, axis: Axis::X, delta }); + None + } + (None, Some(delta), None) => { + g.bond(AxisBond { lo, hi, ends, axis: Axis::Y, delta }); + None + } + (None, None, None) => None, + }; + if let Some((v, frame)) = offset { + let base = g.at(lo).unwrap_or_default(); + let pts = BTreeMap::from([(lo, base), (hi, base + v)]); + pairs.push(Some(Cluster { frame, pts, via: BTreeMap::from([(lo, ends.0), (hi, ends.1)]), fresh: vec![lo, hi] })); + } + if pairs.len() > MAX_CLUSTERS { + return None; + } + } + // Kept even when nothing is known yet: floating clusters join it once placed. + g.ground = Some(g.clusters.len()); + g.clusters.push(Some(ground)); + g.clusters.extend(pairs); + for (id, c) in g.clusters.iter().enumerate() { + for node in c.iter().flat_map(|c| c.pts.keys()) { + if let Some(list) = g.member.get_mut(*node) { + list.push(id); + } + } + } + g.queued = vec![true; g.clusters.len()]; + g.queue = (0..g.clusters.len()).collect(); + Some(g) + } + + /// Point `i` is known at `at`: add it to the ground and queue the other clusters holding it + /// (they find the ground through it). + fn know(&mut self, i: usize, at: Vec2) { + let node = self.node(i); + let Some(id) = self.ground else { return }; + let Some(Some(ground)) = self.clusters.get_mut(id) else { return }; + let Entry::Vacant(slot) = ground.pts.entry(node) else { return }; + slot.insert(at); + ground.via.insert(node, i); + self.join(node, id); + } + + /// `node` joined cluster `id`: queue the other clusters holding it (they meet `id` through + /// it). + fn join(&mut self, node: usize, id: usize) { + let Graph { member, queued, queue, .. } = self; + if let Some(list) = member.get_mut(node) { + for c in list.iter() { + enqueue(queued, queue, *c); + } + list.push(id); + } + } + + /// Record an axis bond (it makes no cluster of its own). + fn bond(&mut self, b: AxisBond) { + let id = self.bonds.len(); + self.bonds.push(b); + for e in [b.lo, b.hi] { + if let Some(list) = self.bonds_of.get_mut(e) { + list.push(id); + } + } + } + + /// Representative node of point `i`. + fn node(&self, i: usize) -> usize { + self.node_of.get(i).copied().unwrap_or(i) + } + + /// Reference position of a point (`None`: unknown). + fn at(&self, point: usize) -> Option { + self.reading.before(point) + } + + /// Reference position of `node` as cluster `id` constrains it. + fn at_node(&self, id: usize, node: usize) -> Option { + self.at(self.cluster(id).and_then(|c| c.via.get(&node)).copied().unwrap_or(node)) + } + + fn cluster(&self, id: usize) -> Option<&Cluster> { + self.clusters.get(id).and_then(Option::as_ref) + } + + fn pos(&self, id: usize, node: usize) -> Option { + self.cluster(id)?.pts.get(&node).copied() + } + + fn frame(&self, id: usize) -> Frame { + self.cluster(id).map_or(Frame::Rigid, |c| c.frame) + } + + /// Nodes `x` and `y` share. + fn common(&self, x: usize, y: usize) -> Vec { + let (Some(a), Some(b)) = (self.cluster(x), self.cluster(y)) else { return Vec::new() }; + let (small, big) = if a.pts.len() <= b.pts.len() { (a, b) } else { (b, a) }; + small.pts.keys().copied().filter(|n| big.pts.contains_key(n)).collect() + } + + /// The cost of scanning a rigid cluster's nodes and their axis bonds (only rigid + /// clusters turn on bonds). + fn bond_load(&self, id: usize) -> usize { + self.cluster(id).filter(|c| c.frame == Frame::Rigid).map_or(0, |c| c.pts.keys().map(|n| 1 + self.bonds_of.get(*n).map_or(0, Vec::len)).sum()) + } + + /// Merge and grow clusters until none of the steps applies. A cluster is queued at most + /// once at a time, and only again after a step changed it or a cluster it shares a node + /// with; every pop and scan is charged to the work budget, which bounds the loop. + fn reduce(&mut self) { + while let Some(id) = self.queue.pop_front() { + if let Some(q) = self.queued.get_mut(id) { + *q = false; + } + self.work += 1; + if self.work > self.limit { + return; + } + if let Some(m) = self.find_merge(id) { + self.absorb(m); + } else if self.grow(id) { + enqueue(&mut self.queued, &mut self.queue, id); + } + } + } + + /// The first step `id` takes part in. + fn find_merge(&mut self, id: usize) -> Option { + self.work += self.bond_load(id); + if let Some(m) = self.orient(id) { + return Some(m); + } + let nodes: Vec = self.cluster(id)?.pts.keys().copied().collect(); + // Pairs first, stopping at the first merge: a busy node's member list is read only + // up to the first partner. + let (mut tried, mut work) = (BTreeSet::new(), 0); + let found = 'pairs: { + for other in nodes.iter().flat_map(|n| self.member.get(*n).into_iter().flatten()) { + work += 1; + if self.work + work > self.limit { + break 'pairs None; + } + let Some(size) = self.cluster(*other).map(|c| c.pts.len()) else { continue }; + if *other == id || !tried.insert(*other) { + continue; + } + work += size.min(nodes.len()); + if let Some(m) = self.pair(id, *other, &self.common(id, *other)) { + break 'pairs Some(m); + } + } + None + }; + self.work += work; + if found.is_some() || self.work > self.limit { + return found; + } + // Neighbours with the nodes they share (dropping merged-away clusters on the way). + let mut shared: BTreeMap> = BTreeMap::new(); + for node in &nodes { + let clusters = &self.clusters; + let Some(list) = self.member.get_mut(*node) else { continue }; + list.retain(|c| clusters.get(*c).is_some_and(Option::is_some)); + self.work += list.len(); + for other in list.iter().filter(|c| **c != id) { + shared.entry(*other).or_default().push(*node); + } + } + // Triangles through the node index: y shares `a` with x, z holds a node `b` of y + // and shares another node `c` with x. + let mut work = 0; + let found = 'search: { + let xc = self.cluster(id)?; + for (y, ys) in &shared { + let (Some(&a), Some(yc)) = (ys.first(), self.cluster(*y)) else { continue }; + for b in yc.pts.keys().filter(|b| !xc.pts.contains_key(b)) { + work += 1; + for z in self.member.get(*b).into_iter().flatten() { + work += 1; + if self.work + work > self.limit { + break 'search None; + } + let Some(c) = shared.get(z).filter(|_| z != y).and_then(|zs| zs.iter().find(|c| **c != a)) else { continue }; + if let Some(m) = self.triangle([id, *y, *z], [a, *b, *c]) { + break 'search Some(m); + } + } + } + } + None + }; + self.work += work; + if found.is_some() || self.work > self.limit { + return found; + } + for (other, nodes) in &shared { + if let [pivot] = nodes.as_slice() { + self.work += self.bond_load(id) + self.bond_load(*other); + if self.work > self.limit { + return None; + } + if let Some(m) = self.hinge(id, *other, *pivot) { + return Some(m); + } + } + } + None + } + + /// A rigid cluster holding both ends of an axis bond turns until the bond holds and is + /// then oriented. Of the two turns, the one keeping the bond's other component on its + /// reference side. Bonds whose ends coincide in the cluster can't turn it; the next is + /// tried. + fn orient(&self, id: usize) -> Option { + let c = self.cluster(id).filter(|c| c.frame == Frame::Rigid)?; + c.pts.keys().flat_map(|n| self.bonds_of.get(*n).into_iter().flatten()).filter_map(|b| self.bonds.get(*b)).find_map(|bond| { + let (lo, hi) = (*c.pts.get(&bond.lo)?, *c.pts.get(&bond.hi)?); + let target = on_axis(bond.axis, lo, lo.dist(hi), bond.delta, self.at(bond.ends.0), self.at(bond.ends.1))?; + let m = Motion::onto(lo, hi, lo, target)?; + Some(Merge { frame: Frame::Oriented, parts: vec![(id, m)] }) + }) + } + + /// Two clusters sharing points: two non-rigid ones by translation, a rigid one onto two + /// shared points. + fn pair(&self, x: usize, y: usize, shared: &[usize]) -> Option { + // The least free frame is kept; between equals the larger cluster stays put. + let key = |c: usize| (self.frame(c), std::cmp::Reverse(self.cluster(c).map_or(0, |c| c.pts.len()))); + let (base, moved) = if key(y) < key(x) { (y, x) } else { (x, y) }; + let a = *shared.first()?; + let (wa, ua) = (self.pos(base, a)?, self.pos(moved, a)?); + let m = if self.frame(moved) != Frame::Rigid { + Motion::translate(ua, wa) + } else { + // The other shared point farthest from `a` gives the best-conditioned direction. + let far = |n: &usize| self.pos(base, *n).map_or(0.0, |w| w.dist(wa)); + let b = shared.iter().skip(1).max_by(|p, q| far(p).total_cmp(&far(q)))?; + Motion::onto(ua, self.pos(moved, *b)?, wa, self.pos(base, *b)?)? + }; + Some(Merge { frame: self.frame(base), parts: vec![(base, Motion::IDENTITY), (moved, m)] }) + } + + /// Owen's triangle step: clusters `[x, y, z]` hold the distinct nodes `[a, b, c]` as + /// x ∋ c, a; y ∋ a, b; z ∋ b, c, so the distances between them are known. Construct the + /// triangle in the frame of the least free cluster, keeping its reference orientation, + /// and move the other two (rigid) onto it. + fn triangle(&self, [x, y, z]: [usize; 3], [a, b, c]: [usize; 3]) -> Option { + // Each cluster with the two shared nodes it holds, in cyclic order (previous, next). + let held = [(x, c, a), (y, a, b), (z, b, c)]; + let base = (0..3).min_by_key(|i| held.get(*i).map(|h| self.frame(h.0)))?; + let (kp, p, r) = *held.get(base)?; + let (kr, _, q) = *held.get((base + 1) % 3)?; + let (kq, ..) = *held.get((base + 2) % 3)?; + if self.frame(kr) != Frame::Rigid || self.frame(kq) != Frame::Rigid { + return None; + } + let (wp, wr) = (self.pos(kp, p)?, self.pos(kp, r)?); + let (rr, rq) = (self.pos(kr, r)?, self.pos(kr, q)?); + let (qq, qp) = (self.pos(kq, q)?, self.pos(kq, p)?); + let (to_p, to_r) = (qp.dist(qq), rr.dist(rq)); + let circles = |p: Vec2, r: Vec2| (Locus::Circle { c: p, r: to_p }, Locus::Circle { c: r, r: to_r }); + let then = self.at_node(kp, p).zip(self.at_node(kp, r)).map(|(p, r)| circles(p, r)); + // q keeps the turn p→r→q it has in the reference. + let wq = crossing(circles(wp, wr), then, self.at_node(kr, q))?; + let (mr, mq) = (Motion::onto(rr, rq, wr, wq)?, Motion::onto(qq, qp, wq, wp)?); + Some(Merge { frame: self.frame(kp), parts: vec![(kp, Motion::IDENTITY), (kr, mr), (kq, mq)] }) + } + + /// A rigid cluster pinned at one point of a non-rigid one turns about the pin until an + /// axis bond between them holds (circle ∩ axis line). The branch keeps the side of the + /// pin the bonded point is on in the reference. + fn hinge(&self, x: usize, y: usize, pivot: usize) -> Option { + let (base, moved) = if self.frame(y) < self.frame(x) { (y, x) } else { (x, y) }; + if self.frame(base) == Frame::Rigid || self.frame(moved) != Frame::Rigid { + return None; + } + let (bc, mc) = (self.cluster(base)?, self.cluster(moved)?); + let (centre, local_pivot) = (*bc.pts.get(&pivot)?, *mc.pts.get(&pivot)?); + let pin = self.at_node(moved, pivot); + mc.pts.iter().filter(|(n, _)| **n != pivot).find_map(|(u, local_u)| { + let radius = local_u.dist(local_pivot); + self.bonds_of.get(*u).into_iter().flatten().filter_map(|b| self.bonds.get(*b)).find_map(|bond| { + let (other, delta, end) = if bond.hi == *u { (bond.lo, bond.delta, bond.ends.1) } else { (bond.hi, -bond.delta, bond.ends.0) }; + let (along, _) = bond.axis.split(*bc.pts.get(&other)? - centre); + let target = on_axis(bond.axis, centre, radius, along + delta, pin, self.at(end))?; + let m = Motion::onto(local_pivot, *local_u, centre, target)?; + Some(Merge { frame: self.frame(base), parts: vec![(base, Motion::IDENTITY), (moved, m)] }) + }) + }) + } + + /// Owen's single-point merge: every point outside cluster `id` that its relations and + /// ties to the cluster's points place at one crossing joins it (in the cluster's frame), + /// and the clusters holding it are queued to meet the grown one. Only the neighbours of + /// nodes not tried before are tried (a point's placement changes only when a point it is + /// related to joins). The ground grows by propagation instead. True when a point joined. + fn grow(&mut self, id: usize) -> bool { + if self.ground == Some(id) { + return false; + } + let Some(c) = self.clusters.get_mut(id).and_then(Option::as_mut) else { return false }; + let oriented = c.frame != Frame::Rigid; + let mut scan = std::mem::take(&mut c.fresh); + let mut grown = false; + // Out of budget: the nodes not scanned stay fresh (nothing more merges anyway). + let stop = |g: &mut Self, node: usize, scan: Vec| { + if let Some(Some(c)) = g.clusters.get_mut(id) { + c.fresh.extend(scan); + c.fresh.push(node); + } + }; + while let Some(node) = scan.pop() { + // Points outside the cluster related to a point of `node`. + let mut candidates = BTreeSet::new(); + for p in self.points_of.get(node).into_iter().flatten() { + let woken = self.reading.woken(*p); + self.work += woken.len(); + for o in self.reading.related(*p).chain(woken) { + self.work += 1; + if self.work > self.limit { + stop(self, node, scan); + return grown; + } + if self.cluster(id).is_some_and(|c| !c.pts.contains_key(&self.node(o))) { + candidates.insert(o); + } + } + } + for t in candidates { + if self.work > self.limit { + stop(self, node, scan); + return grown; + } + let joining = self.node(t); + let Some(c) = self.cluster(id).filter(|c| !c.pts.contains_key(&joining)) else { continue }; + let at = |i: usize| c.pts.get(&self.node(i)).copied(); + let items = self.reading.items(t, &View { at: &at, oriented }); + let fix = place(&items, self.at(t)).filter(|f| f.transversal); + self.work += 1 + self.reading.adj(t).len() + self.reading.tie_count_of(t) + items.len().saturating_mul(items.len()); + let Some(fix) = fix else { continue }; + if let Some(Some(c)) = self.clusters.get_mut(id) { + c.pts.insert(joining, fix.at); + c.via.insert(joining, t); + } + self.join(joining, id); + scan.push(joining); + grown = true; + } + } + grown + } + + /// See [`Decomposition::floats`]. Charged a scan of the clusters and their relations. + fn floats(&mut self, anchor: &dyn Fn(usize) -> Option) -> Vec> { + let node_at = |n: usize| self.points_of.get(n).into_iter().flatten().find_map(|i| anchor(*i)); + let mut work = 0; + // (pinned first, larger first, lower slot first), slot, pin with its position. + let mut picks = Vec::new(); + for (id, c) in self.clusters.iter().enumerate() { + let Some(c) = c.as_ref().filter(|_| self.ground != Some(id)) else { continue }; + work += c.pts.len(); + if self.work + work > self.limit { + self.work += work; + return Vec::new(); + } + if c.pts.keys().any(|n| self.conflicted.get(*n).copied().unwrap_or(false)) { + continue; + } + let mut pins = c.pts.keys().filter_map(|n| Some((*n, node_at(*n)?))); + let (pin, more) = (pins.next(), pins.next().is_some()); + let eligible = match c.frame { + Frame::Rigid => c.pts.len() >= 3 && !more, + Frame::Oriented => c.pts.len() >= 2 && pin.is_none(), + Frame::Ground => false, + }; + if eligible && (pin.is_some_and(|(n, at)| self.at_node(id, n) != Some(at)) || (self.edits && self.misses(id, &mut work))) { + picks.push(((pin.is_none(), std::cmp::Reverse(c.pts.len()), id), pin)); + } + } + self.work += work; + picks.sort_by_key(|(key, _)| *key); + picks.into_iter().filter_map(|((.., id), pin)| self.pose(id, pin)).collect() + } + + /// A relation or tie among cluster `id`'s points misses at the reference (or a point has + /// no reference position). Charges `work`. + fn misses(&self, id: usize, work: &mut usize) -> bool { + let Some(c) = self.cluster(id) else { return false }; + let inside = |i: usize| c.pts.contains_key(&self.node(i)).then(|| self.at(i)).flatten(); + let view = View { at: &inside, oriented: true }; + c.pts.keys().flat_map(|n| self.points_of.get(*n).into_iter().flatten()).any(|i| { + *work += 1 + self.reading.adj(*i).len() + self.reading.tie_count_of(*i); + self.at(*i).is_none_or(|at| !self.reading.holds(*i, at, &view)) + }) + } + + /// Cluster `id` where its reference pose puts it (see the module doc), pinned at a node + /// placed at a position if any: each of its points but the pin's, with its position. None + /// when the reference decides no pose. + fn pose(&self, id: usize, pin: Option<(usize, Vec2)>) -> Option> { + let c = self.cluster(id)?; + let moved = || c.pts.iter().filter(move |(n, _)| pin.is_none_or(|(p, _)| **n != p)); + // (position in the cluster's frame, reference position) of each node but the pin. + let pairs: Vec<(Vec2, Vec2)> = moved().filter_map(|(n, p)| Some((*p, self.at_node(id, *n)?))).collect(); + let centroids = || { + let (u, w) = pairs.iter().fold((Vec2::ZERO, Vec2::ZERO), |(su, sw), (u, w)| (su + *u, sw + *w)); + (u / pairs.len() as f64, w / pairs.len() as f64) + }; + // The point the cluster turns about, in its frame and at its reference, where that + // point goes, and whether it turns. + let (from, about, to, turns) = match (c.frame, pin) { + (Frame::Oriented, None) if !pairs.is_empty() => { + let (from, to) = centroids(); + (from, to, to, false) + } + (Frame::Rigid, Some((pin, at))) => (*c.pts.get(&pin)?, self.at_node(id, pin).unwrap_or(at), at, true), + (Frame::Rigid, None) if pairs.len() >= 2 => { + let (from, to) = centroids(); + (from, to, to, true) + } + _ => return None, + }; + let rot = if turns { best_turn(pairs.iter().map(|(u, w)| (*u - from, *w - about)))? } else { Vec2::X }; + let motion = Motion { rot, from, to }; + let out: Vec<(usize, Vec2)> = + moved().flat_map(|(n, p)| self.points_of.get(*n).into_iter().flatten().map(move |i| (*i, motion.apply(*p)))).collect(); + out.iter().all(|(_, at)| at.is_finite()).then_some(out) + } + + /// Replace the merged clusters by their union, kept in the first one's slot (so only the + /// nodes joining it change membership), and queue it. + fn absorb(&mut self, m: Merge) { + let Some(&(base, base_motion)) = m.parts.first() else { return }; + let Some(mut merged) = self.clusters.get_mut(base).and_then(Option::take) else { return }; + merged.frame = m.frame; + // Only `orient` moves the base (a merge's base stays put, and may be the ground); an + // oriented cluster reads relations along the axes, so every node is tried again. + if m.parts.len() == 1 { + self.work += merged.pts.len(); + for p in merged.pts.values_mut() { + *p = base_motion.apply(*p); + } + merged.fresh = merged.pts.keys().copied().collect(); + } + for (id, motion) in m.parts.iter().skip(1) { + let Some(c) = self.clusters.get_mut(*id).and_then(Option::take) else { continue }; + self.work += c.pts.len(); + for (node, p) in c.pts { + if let Entry::Vacant(slot) = merged.pts.entry(node) { + slot.insert(motion.apply(p)); + merged.fresh.push(node); + if let Some(point) = c.via.get(&node) { + merged.via.entry(node).or_insert(*point); + } + if let Some(list) = self.member.get_mut(node) { + list.push(base); + } + } + } + } + // The ground grows by propagation: it keeps no nodes to try. + if merged.frame == Frame::Ground { + merged.fresh.clear(); + } + if let Some(slot) = self.clusters.get_mut(base) { + *slot = Some(merged); + } + enqueue(&mut self.queued, &mut self.queue, base); + } +} + +/// Push `i` onto a work queue unless `queued[i]` says it is waiting there already. +pub(crate) fn enqueue(queued: &mut [bool], queue: &mut VecDeque, i: usize) { + if let Some(q) = queued.get_mut(i) + && !*q + { + *q = true; + queue.push_back(i); + } +} + +#[cfg(test)] +#[path = "decompose_tests.rs"] +mod tests; diff --git a/crates/sketch/src/decompose_tests.rs b/crates/sketch/src/decompose_tests.rs new file mode 100644 index 00000000..9f4fd08a --- /dev/null +++ b/crates/sketch/src/decompose_tests.rs @@ -0,0 +1,1085 @@ +//! Cluster decomposition: the canonical bracket (no propagation order from the fixed points), +//! its random family under large dimension edits, fans, a triconnected linkage left to the +//! numerical solver, and hostile input. +//! +//! `gap_counter` (ignored; run with `--ignored --nocapture`) measures how often the numerical +//! solver alone mirrors a rigid sub-triangle on each family, and what the decomposition does. +use super::*; +use crate::construct::switch; +use crate::model::{Constraint, ConstraintKind, CurveKind, Sketch}; +use crate::solver::{SolveReport, solve}; + +use crate::tests::Lcg; + +fn known(sk: &Sketch) -> Vec> { + sk.locked_points().iter().zip(&sk.points).map(|(l, p)| l.then_some(p.pos)).collect() +} + +fn current(sk: &Sketch) -> Vec { + sk.points.iter().map(|p| p.pos).collect() +} + +/// The decomposition of `sk` relative to `known`, `reference` covering the points it has. +fn decompose_from(sk: &Sketch, known: &[Option], reference: &[Vec2]) -> Placed { + let reading = crate::loci::read( + sk, + &|i| reference.get(i).or(sk.points.get(i).map(|p| &p.pos)).copied(), + &crate::solver::points_on_curves(sk), + &crate::loci::radii(sk), + ); + let mut d = Decomposition::new(&reading, known, true, MAX_WORK); + Placed { pos: (0..sk.points.len()).map(|i| d.pos(i)).collect() } +} + +/// Every point's position in the ground cluster. +#[derive(Debug)] +struct Placed { + pos: Vec>, +} + +/// A constraint pushed without validation (hostile and oversized inputs). +fn raw(kind: ConstraintKind) -> Constraint { + Constraint { id: "k".into(), kind, param: None, driven: false, text: None } +} + +/// A point pushed past the sketch's own size cap. +fn push_point(sk: &mut Sketch, pos: Vec2) { + let id = format!("p{}", sk.points.len()); + sk.points.push(crate::model::SPoint { id, pos, fixed: false, link: None }); +} + +/// Every point of `ids` placed at its `want` position. +fn assert_placed(d: &Placed, ids: &[usize], want: &[Vec2]) { + for (id, w) in ids.iter().zip(want) { + let at = d.pos[*id].unwrap_or_else(|| panic!("point {id} unplaced")); + assert!(at.dist(*w) < 1e-9, "point {id}: {at:?} vs {w:?}"); + } +} + +/// The sketch with the decomposed positions written in. +fn seeded(sk: &Sketch, d: &Placed) -> Sketch { + let mut out = sk.clone(); + for (p, pos) in out.points.iter_mut().zip(&d.pos) { + if let Some(v) = pos { + p.pos = *v; + } + } + out +} + +/// An orientation the edit must keep: the turn of three points, or which side of `p` a point +/// `q` is on along an axis. +#[derive(Clone, Copy, Debug)] +enum Check { + Turn(usize, usize, usize), + Side(usize, usize, Axis), +} + +fn signature(checks: &[Check], pos: &[Vec2]) -> Vec { + checks + .iter() + .map(|c| match *c { + Check::Turn(a, b, c) => (pos[b] - pos[a]).cross(pos[c] - pos[a]) > 0.0, + Check::Side(p, q, Axis::X) => pos[q].x > pos[p].x, + Check::Side(p, q, Axis::Y) => pos[q].y > pos[p].y, + }) + .collect() +} + +/// Every check well away from degenerate (so "mirrored" is unambiguous). +fn clear(checks: &[Check], pos: &[Vec2], size: f64) -> bool { + checks.iter().all(|c| match *c { + Check::Turn(a, b, c) => { + let (u, v, w) = (pos[b] - pos[a], pos[c] - pos[a], pos[c] - pos[b]); + let edge = u.len2().max(v.len2()).max(w.len2()); + u.cross(v).abs() > 0.05 * edge && u.len().min(v.len()).min(w.len()) > 0.05 * size + } + Check::Side(p, q, Axis::X) => (pos[q].x - pos[p].x).abs() > 0.05 * size, + Check::Side(p, q, Axis::Y) => (pos[q].y - pos[p].y).abs() > 0.05 * size, + }) +} + +struct Case { + sk: Sketch, + /// The solution the edit asks for (keeps every check's orientation). + target: Option>, + checks: Vec, + size: f64, +} + +/// How the bracket's datum (O1, O2) is held. +#[derive(Clone, Copy, Debug)] +enum Datum { + /// O1 and O2 fixed. + Fixed, + /// O1 is the sketch origin; O2 on a horizontal line of given length from it. + Horizontal, + /// O1 fixed; O2 at a DistanceX and a DistanceY from it. + Offsets, + /// O1 fixed, no O2; Q horizontal with O1 (the rigid bracket turns about O1). + Hinge, +} + +const DATUMS: [Datum; 4] = [Datum::Fixed, Datum::Horizontal, Datum::Offsets, Datum::Hinge]; + +/// The canonical bracket with random sizes: O1, O2 held; rigid triangle P Q R (|PQ| |QR| |PR|); +/// |O1P|, |O1R|, |O2Q|. No point has two placed neighbours. The sketch starts at a random +/// layout and its dimensions come from a second one with the same orientations, every +/// dimension changed by 0.5–2.5×. +fn bracket(rng: &mut Lcg, datum: Datum, scale: f64) -> Option { + let size = 60.0 * scale; + let o1 = match datum { + Datum::Horizontal => Vec2::ZERO, + _ => Vec2::new(rng.range(-200.0, 200.0), rng.range(-200.0, 200.0)) * scale, + }; + let rnd = |rng: &mut Lcg| o1 + Vec2::new(rng.range(-size, size), rng.range(-size, size)); + // [O1, O2, P, Q, R] + let mut l0 = [o1, rnd(rng), rnd(rng), rnd(rng), rnd(rng)]; + let mut l1 = [o1, l0[1], rnd(rng), rnd(rng), rnd(rng)]; + match datum { + Datum::Fixed => {} + Datum::Horizontal => { + l0[1].y = o1.y; + l1[1] = o1 + Vec2::new((l0[1].x - o1.x).signum() * rng.range(0.1, 1.0) * size, 0.0); + } + Datum::Offsets => { + let d = l0[1] - o1; + l1[1] = o1 + Vec2::new(d.x.signum() * rng.range(0.1, 1.0) * size, d.y.signum() * rng.range(0.1, 1.0) * size); + } + Datum::Hinge => { + l0[3].y = o1.y; + l1[3].y = o1.y; + } + } + let (o1i, o2i, p, q, r) = (0, 1, 2, 3, 4); + let mut pairs = vec![(p, q), (q, r), (p, r), (o1i, p), (o1i, r)]; + let mut checks = vec![Check::Turn(p, q, r), Check::Turn(o1i, p, r)]; + match datum { + Datum::Hinge => checks.push(Check::Side(o1i, q, Axis::X)), + _ => { + pairs.push((o2i, q)); + checks.push(Check::Turn(o1i, o2i, q)); + } + } + if matches!(datum, Datum::Horizontal) { + pairs.push((o1i, o2i)); + } + let ratio_ok = |a: f64, b: f64| b > 0.5 * a && b < 2.5 * a; + let mut ok = pairs.iter().all(|&(a, b)| ratio_ok(l0[a].dist(l0[b]), l1[a].dist(l1[b]))) + && clear(&checks, &l0, size) + && clear(&checks, &l1, size) + && signature(&checks, &l0) == signature(&checks, &l1); + if matches!(datum, Datum::Offsets) { + let (d0, d1) = (l0[1] - o1, l1[1] - o1); + ok &= ratio_ok(d0.x.abs(), d1.x.abs()) && ratio_ok(d0.y.abs(), d1.y.abs()); + } + for a in 0..5 { + for b in a + 1..5 { + ok &= l0[a].dist(l0[b]) > 0.05 * size && l1[a].dist(l1[b]) > 0.05 * size; + } + } + if !ok { + return None; + } + + let mut sk = Sketch::new(); + let mut ids = [0usize; 5]; + for (i, pos) in l0.iter().enumerate() { + ids[i] = match (i, datum) { + (0, Datum::Horizontal) => 0, + (1, Datum::Hinge) => usize::MAX, + _ => sk.add_point(*pos, None).ok()?, + }; + } + let fix = |sk: &mut Sketch, p: usize| sk.add_constraint(ConstraintKind::Fix { p }, None).map(|_| ()); + match datum { + Datum::Fixed => { + fix(&mut sk, ids[0]).ok()?; + fix(&mut sk, ids[1]).ok()?; + } + Datum::Horizontal => { + let l = sk.add_line_pts(ids[0], ids[1], None).ok()?; + sk.add_constraint(ConstraintKind::Horizontal { l }, None).ok()?; + sk.add_constraint(ConstraintKind::Length { l, value: l1[0].dist(l1[1]) }, None).ok()?; + } + Datum::Offsets => { + fix(&mut sk, ids[0]).ok()?; + let d = l1[1] - l1[0]; + sk.add_constraint(ConstraintKind::DistanceX { p: ids[0], q: ids[1], value: d.x.abs() }, None).ok()?; + sk.add_constraint(ConstraintKind::DistanceY { p: ids[0], q: ids[1], value: d.y.abs() }, None).ok()?; + } + Datum::Hinge => { + fix(&mut sk, ids[0]).ok()?; + sk.add_constraint(ConstraintKind::HorizontalPoints { p: ids[0], q: ids[3] }, None).ok()?; + } + } + for &(a, b) in pairs.iter().filter(|(a, b)| (*a, *b) != (o1i, o2i)) { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: l1[a].dist(l1[b]) }, None).ok()?; + } + let mut target = current(&sk); + for (i, id) in ids.iter().enumerate() { + if let Some(t) = target.get_mut(*id) { + *t = l1[i]; + } + } + let at = |i: usize| ids[i]; + let checks = checks + .into_iter() + .map(|c| match c { + Check::Turn(a, b, c) => Check::Turn(at(a), at(b), at(c)), + Check::Side(a, b, axis) => Check::Side(at(a), at(b), axis), + }) + .collect(); + Some(Case { sk, target: Some(target), checks, size }) +} + +/// Fan-triangulated k-gon, v0 v1 fixed (dimensioned instead when not `anchored`: the polygon +/// floats), every other dimension scaled by `lo..hi`; only cases where every fan triangle stays +/// feasible. +fn fan(rng: &mut Lcg, k: usize, lo: f64, hi: f64, anchored: bool) -> Option { + let pts: Vec = (0..k) + .map(|i| { + let t = i as f64 / k as f64 * std::f64::consts::TAU; + let r = rng.range(30.0, 40.0); + Vec2::new(r * t.cos(), r * t.sin()) + }) + .collect(); + let mut dist = std::collections::HashMap::new(); + for i in 1..k { + dist.insert((i, (i + 1) % k), pts[i].dist(pts[(i + 1) % k]) * rng.range(lo, hi)); + } + for i in 2..k - 1 { + dist.insert((0, i), pts[0].dist(pts[i]) * rng.range(lo, hi)); + } + dist.insert((0, 1), pts[0].dist(pts[1])); + dist.insert((0, k - 1), dist[&(k - 1, 0)]); + let feasible = (1..k - 1).all(|i| { + let (a, b, c) = (dist[&(0, i)], dist[&(i, i + 1)], dist[&(0, i + 1)]); + a + b > c * 1.02 && a + c > b * 1.02 && b + c > a * 1.02 + }); + if !feasible { + return None; + } + // The sketch is built only for feasible draws (most are rejected). + let mut sk = Sketch::new(); + let ids: Vec = pts.iter().map(|p| sk.add_point(*p, None)).collect::>().ok()?; + if anchored { + for &p in &ids[..2] { + sk.add_constraint(ConstraintKind::Fix { p }, None).ok()?; + } + } else { + sk.add_constraint(ConstraintKind::Distance { p: ids[0], q: ids[1], value: dist[&(0, 1)] }, None).ok()?; + } + for i in 1..k { + let j = (i + 1) % k; + sk.add_constraint(ConstraintKind::Distance { p: ids[i], q: ids[j], value: dist[&(i, j)] }, None).ok()?; + } + for i in 2..k - 1 { + sk.add_constraint(ConstraintKind::Distance { p: ids[0], q: ids[i], value: dist[&(0, i)] }, None).ok()?; + } + let checks = (1..k - 1).map(|i| Check::Turn(ids[0], ids[i], ids[i + 1])).collect(); + Some(Case { sk, target: None, checks, size: 40.0 }) +} + +/// Two triangles joined by three links (triconnected, Owen §3.3.1 step 4): base 0 fixed, +/// 0–1 horizontal; every other dimension scaled by 0.8–1.25×. +fn linkage(rng: &mut Lcg) -> Option { + const TRUE: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)]; + const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)]; + let mut sk = Sketch::new(); + let ids: Vec = TRUE.iter().map(|p| sk.add_point(Vec2::new(p.0, p.1), None)).collect::>().ok()?; + sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).ok()?; + let l = sk.add_line_pts(ids[0], ids[1], None).ok()?; + sk.add_constraint(ConstraintKind::Horizontal { l }, None).ok()?; + for (a, b) in PAIRS { + let d = sk.points[ids[a]].pos.dist(sk.points[ids[b]].pos) * rng.range(0.8, 1.25); + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: d }, None).ok()?; + } + let checks = vec![Check::Turn(ids[0], ids[1], ids[2]), Check::Turn(ids[3], ids[4], ids[5])]; + Some(Case { sk, target: None, checks, size: 40.0 }) +} + +/// A case from `make`, retried until feasible. +fn sample(rng: &mut Lcg, make: &mut dyn FnMut(&mut Lcg) -> Option) -> Case { + for _ in 0..2_000_000 { + if let Some(c) = make(rng) { + return c; + } + } + panic!("no feasible case in 2000000 draws"); +} + +/// Points reachable by point-by-point construction from the locked points (two loci on placed +/// points: circle, horizontal or vertical line), as the propagation seed does it. +fn propagation_places_all(sk: &Sketch) -> bool { + let mut placed = sk.locked_points(); + let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => Some((*a, *b)), + _ => None, + }; + loop { + let mut loci = vec![(0usize, false, false); sk.points.len()]; + let mut changed = false; + for c in &sk.constraints { + use ConstraintKind::*; + let (p, q, kind) = match c.kind { + Distance { p, q, .. } => (p, q, 0), + Length { l, .. } => match ends(l) { + Some((a, b)) => (a, b, 0), + None => continue, + }, + DistanceX { p, q, .. } | VerticalPoints { p, q } => (p, q, 1), + DistanceY { p, q, .. } | HorizontalPoints { p, q } => (p, q, 2), + Vertical { l } => match ends(l) { + Some((a, b)) => (a, b, 1), + None => continue, + }, + Horizontal { l } => match ends(l) { + Some((a, b)) => (a, b, 2), + None => continue, + }, + Coincident { p, q } => (p, q, 3), + _ => continue, + }; + for (free, base) in [(p, q), (q, p)] { + if placed[base] && !placed[free] { + let l = &mut loci[free]; + match kind { + 0 => l.0 += 1, + 1 => l.1 = true, + 2 => l.2 = true, + _ => l.0 += 2, + } + } + } + } + for (i, (c, x, y)) in loci.into_iter().enumerate() { + if !placed[i] && (c >= 2 || (c >= 1 && (x || y)) || (x && y)) { + placed[i] = true; + changed = true; + } + } + if !changed { + return placed.iter().all(|p| *p); + } + } +} + +/// Same status, degrees of freedom and determined flags. +fn same_analysis(a: &SolveReport, b: &SolveReport) -> bool { + a.status == b.status && a.dof == b.dof && a.point_determined == b.point_determined && a.curve_determined == b.curve_determined +} + +#[test] +fn canonical_bracket_is_placed_exactly() { + // O1 O2 fixed; rigid P Q R; |O1P| |O1R| |O2Q|. + let truth = [Vec2::new(0.0, 0.0), Vec2::new(60.0, 0.0), Vec2::new(10.0, 30.0), Vec2::new(45.0, 35.0), Vec2::new(25.0, 55.0)]; + let checks = [Check::Turn(2, 3, 4), Check::Turn(0, 2, 4), Check::Turn(0, 1, 3)]; + // At the solution, and with the free points 6 mm away as after an edit. The shifted start + // reverses O1→P→R (nearly collinear), so its orientation-keeping solution is a different + // one of the eight; it must still be exact. + for shift in [Vec2::ZERO, Vec2::new(6.0, -6.0)] { + let mut sk = Sketch::new(); + let ids: Vec = truth.iter().enumerate().map(|(i, p)| sk.add_point(if i < 2 { *p } else { *p + shift }, None).unwrap()).collect(); + for &p in &ids[..2] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + for (a, b) in [(2, 3), (3, 4), (2, 4), (0, 2), (0, 4), (1, 3)] { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: truth[a].dist(truth[b]) }, None).unwrap(); + } + assert!(!propagation_places_all(&sk), "the canonical case has no propagation order"); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + let placed: Vec = ids.iter().map(|id| d.pos[*id].unwrap()).collect(); + let start: Vec = ids.iter().map(|id| sk.points[*id].pos).collect(); + assert_eq!(signature(&checks, &placed), signature(&checks, &start), "every triangle keeps its orientation"); + if shift == Vec2::ZERO { + assert!(placed.iter().zip(&truth).all(|(p, t)| p.dist(*t) < 1e-12), "{placed:?}"); + } + // Placed by the cluster solve: every point not known on entry (P Q R). + let given = known(&sk); + assert!(ids.iter().all(|id| d.pos[*id].is_some()) && ids[2..].iter().all(|id| given[*id].is_none())); + let mut s = seeded(&sk, &d); + let rep = solve(&mut s); + assert!(rep.ok() && rep.dof == 0, "{rep:?}"); + assert_eq!(rep.iterations, 0, "decomposed positions satisfy every constraint already"); + } +} + +/// The ground grows by propagation, never by the single-point merge: merging clusters into it +/// leaves it no nodes to try. +#[test] +fn the_ground_has_no_nodes_to_grow() { + let truth = [Vec2::new(0.0, 0.0), Vec2::new(60.0, 0.0), Vec2::new(10.0, 30.0), Vec2::new(45.0, 35.0), Vec2::new(25.0, 55.0)]; + let mut sk = Sketch::new(); + let ids: Vec = truth.iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + for &p in &ids[..2] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + for (a, b) in [(2, 3), (3, 4), (2, 4), (0, 2), (0, 4), (1, 3)] { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: truth[a].dist(truth[b]) }, None).unwrap(); + } + let reading = crate::loci::read(&sk, &|i| sk.points.get(i).map(|p| p.pos), &crate::solver::points_on_curves(&sk), &crate::loci::radii(&sk)); + let given = known(&sk); + let mut g = Graph::build(&reading, &|i| given.get(i).copied().flatten(), MAX_WORK).unwrap(); + g.reduce(); + let ground = g.cluster(g.ground.unwrap()).unwrap(); + assert!(ids.iter().all(|id| ground.pts.contains_key(id)), "the bracket reached the ground"); + assert!(ground.fresh.is_empty(), "{:?}", ground.fresh); +} + +#[test] +fn bracket_family_keeps_every_triangle_under_large_edits() { + let mut rng = Lcg(7); + for datum in DATUMS { + for scale in [0.2, 1.0, 10.0] { + for _ in 0..40 { + let case = sample(&mut rng, &mut |r| bracket(r, datum, scale)); + assert!(!propagation_places_all(&case.sk), "{datum:?}: propagation alone would place it"); + let d = decompose_from(&case.sk, &known(&case.sk), ¤t(&case.sk)); + let target = case.target.as_ref().unwrap(); + for (i, t) in target.iter().enumerate() { + let p = d.pos[i].unwrap_or_else(|| panic!("{datum:?} scale {scale}: point {i} unplaced")); + assert!(p.dist(*t) < 1e-9 * case.size.max(1.0) * 10.0, "{datum:?} scale {scale}: point {i} {p:?} vs {t:?}"); + } + let mut s = seeded(&case.sk, &d); + let rep = solve(&mut s); + assert!(rep.ok() && rep.dof == 0, "{datum:?}: {rep:?}"); + assert_eq!(rep.iterations, 0, "{datum:?} scale {scale}"); + assert_eq!(signature(&case.checks, ¤t(&s)), signature(&case.checks, ¤t(&case.sk))); + // The seed changes where the solver starts, never what it reports about the sketch. + let lm = lm_alone(&mut case.sk.clone()); + if lm.ok() { + assert!(same_analysis(&lm, &rep), "{datum:?}: {lm:?} vs {rep:?}"); + } + } + } + } +} + +/// The numerical solver alone, from the sketch's positions (no constructive seed). +fn lm_alone(sk: &mut Sketch) -> SolveReport { + switch::without_seed(|| solve(sk)) +} + +#[test] +fn bracket_family_is_solved_exactly_by_solve() { + // Through `solve`, no hand-written seed: the decomposition inside the seed places every + // point exactly, so LM has nothing to do and no sub-triangle mirrors. + let mut rng = Lcg(11); + for datum in DATUMS { + for scale in [0.2, 1.0, 10.0] { + for _ in 0..40 { + let mut case = sample(&mut rng, &mut |r| bracket(r, datum, scale)); + let before = signature(&case.checks, ¤t(&case.sk)); + let rep = solve(&mut case.sk); + assert!(rep.ok() && rep.dof == 0, "{datum:?} scale {scale}: {rep:?}"); + assert_eq!(rep.iterations, 0, "{datum:?} scale {scale}"); + assert_eq!(signature(&case.checks, ¤t(&case.sk)), before, "{datum:?} scale {scale}: mirrored"); + let target = case.target.as_ref().unwrap(); + for (i, t) in target.iter().enumerate() { + let p = case.sk.points[i].pos; + assert!(p.dist(*t) < 1e-8 * case.size.max(1.0), "{datum:?} scale {scale}: point {i} {p:?} vs {t:?}"); + } + } + } + } +} + +#[test] +fn fan_family_is_solved_exactly_by_solve() { + let mut rng = Lcg(5); + for k in [6, 8, 12] { + for _ in 0..60 { + let mut case = sample(&mut rng, &mut |r| fan(r, k, 0.5, 2.5, true)); + let before = signature(&case.checks, ¤t(&case.sk)); + let rep = solve(&mut case.sk); + assert!(rep.ok() && rep.iterations == 0, "{k}-gon: {rep:?}"); + assert_eq!(signature(&case.checks, ¤t(&case.sk)), before, "{k}-gon mirrored"); + } + } +} + +#[test] +fn fan_family_keeps_every_triangle() { + let mut rng = Lcg(99); + for k in [4, 6, 8, 12] { + for _ in 0..60 { + let case = sample(&mut rng, &mut |r| fan(r, k, 0.5, 2.5, true)); + let d = decompose_from(&case.sk, &known(&case.sk), ¤t(&case.sk)); + assert!(d.pos.iter().all(Option::is_some), "{k}-gon: every point placed"); + let mut s = seeded(&case.sk, &d); + let rep = solve(&mut s); + assert!(rep.ok() && rep.iterations == 0, "{k}-gon: {rep:?}"); + assert_eq!(signature(&case.checks, ¤t(&s)), signature(&case.checks, ¤t(&case.sk)), "{k}-gon mirrored"); + } + } +} + +/// Mean position of the points. +fn centroid(pos: &[Vec2]) -> Vec2 { + pos.iter().fold(Vec2::ZERO, |s, p| s + *p) / pos.len() as f64 +} + +/// A fan with no fixed point is one rigid cluster that never reaches the ground: it is placed +/// by its reference pose (the least-squares rotation and translation onto where it was), so no +/// triangle mirrors, the solver has nothing left to do, the rigid motion stays free (3 degrees +/// of freedom) and the polygon moves no more than the solver alone would move it. +#[test] +fn floating_fan_family_keeps_every_triangle() { + let mut rng = Lcg(31); + let mut lm_mirrored = 0; + for k in [4, 6, 8, 12] { + for _ in 0..60 { + let case = sample(&mut rng, &mut |r| fan(r, k, 0.5, 2.5, false)); + let start = current(&case.sk); + let before = signature(&case.checks, &start); + let mut lm = case.sk.clone(); + let lm_rep = lm_alone(&mut lm); + lm_mirrored += usize::from(lm_rep.ok() && signature(&case.checks, ¤t(&lm)) != before); + let mut s = case.sk.clone(); + let rep = solve(&mut s); + assert!(rep.ok() && rep.dof == 3 && rep.iterations == 0, "{k}-gon: {rep:?}"); + assert_eq!(signature(&case.checks, ¤t(&s)), before, "{k}-gon mirrored"); + if lm_rep.ok() { + let (shift, lm_shift) = (centroid(¤t(&s)).dist(centroid(&start)), centroid(¤t(&lm)).dist(centroid(&start))); + assert!(shift <= lm_shift + 1e-6, "{k}-gon: centroid moved {shift}, the solver alone {lm_shift}"); + } + } + } + eprintln!("floating fans, solver alone: {lm_mirrored}/240 mirrored"); +} + +/// A rigid triangle pinned at one vertex is placed by its reference pose turned about the pin, +/// never reflected, and the turn stays free: a thin triangle (sides 3.9, 2, 2) whose vertex is +/// held and moved 5–40 mm in one jump, seeded on the positions before the move. +#[test] +fn a_cluster_pinned_at_one_point_turns_about_it() { + let mut rng = Lcg(41); + let mut lm_mirrored = 0; + for _ in 0..200 { + let mut sk = Sketch::new(); + let pin = Vec2::new(rng.range(-50.0, 50.0), rng.range(-50.0, 50.0)); + let turn = rng.range(0.0, std::f64::consts::TAU); + let u = Vec2::from_angle(turn); + let n = u.perp() * rng.sign(); + let pts = [pin, pin + u * 3.9, pin + u * 1.95 + n * (4.0f64 - 1.95 * 1.95).sqrt()]; + let ids: Vec = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + for (p, q, value) in [(0, 1, 3.9), (1, 2, 2.0), (2, 0, 2.0)] { + sk.add_constraint(ConstraintKind::Distance { p: ids[p], q: ids[q], value }, None).unwrap(); + } + let checks = [Check::Turn(ids[0], ids[1], ids[2])]; + let reference = current(&sk); + let before = signature(&checks, &reference); + let jump = rng.range(0.0, std::f64::consts::TAU); + let to = pin + Vec2::from_angle(jump) * rng.range(5.0, 40.0); + sk.points[ids[0]].pos = to; + let hold = crate::solver::Hold { points: vec![ids[0]], radii: vec![] }; + let mut lm = sk.clone(); + let lm_rep = switch::without_seed(|| crate::solve_with(&mut lm, &hold, Some(&reference), crate::Analysis::Full, None)); + lm_mirrored += usize::from(lm_rep.ok() && signature(&checks, ¤t(&lm)) != before); + let rep = crate::solve_with(&mut sk, &hold, Some(&reference), crate::Analysis::Full, None); + assert!(rep.ok() && rep.dof == 1 && rep.iterations == 0, "{rep:?}"); + assert_eq!(signature(&checks, ¤t(&sk)), before, "mirrored"); + assert_eq!(sk.points[ids[0]].pos, to); + // Moved with the pin, not turned: the reference pose is kept about it. + for i in 1..3 { + let (was, now) = (reference[ids[i]] - reference[ids[0]], sk.points[ids[i]].pos - to); + assert!(was.dist(now) < 1e-9, "point {i} turned: {was:?} -> {now:?}"); + } + } + eprintln!("thin triangle pinned at a held vertex, one jump, solver alone: {lm_mirrored}/200 mirrored"); +} + +/// A rigid cluster whose reference pose breaks a relation to the placed points (the top of the +/// triconnected linkage, held by three links) is not floated: it is left to the solver. +#[test] +fn a_floated_pose_that_breaks_a_relation_is_left_to_the_solver() { + let mut rng = Lcg(3); + for _ in 0..20 { + let case = sample(&mut rng, &mut linkage); + let out = crate::tests::seed_of(&case.sk); + assert!(out.pos[4..7].iter().all(Option::is_none), "{out:?}"); + } +} + +#[test] +fn triconnected_linkage_is_left_to_the_solver() { + let mut rng = Lcg(3); + for _ in 0..20 { + let case = sample(&mut rng, &mut linkage); + let d = decompose_from(&case.sk, &known(&case.sk), ¤t(&case.sk)); + // The base triangle hangs off the fixed point and the horizontal line; the top + // triangle and its three links are triconnected. + assert!(d.pos[1..4].iter().all(Option::is_some)); + assert!(d.pos[4..7].iter().all(Option::is_none)); + let mut s = seeded(&case.sk, &d); + let (rep, lm) = (solve(&mut s), lm_alone(&mut case.sk.clone())); + if lm.ok() { + assert!(rep.ok() && same_analysis(&lm, &rep), "{lm:?} vs {rep:?}"); + } + } +} + +#[test] +fn branch_follows_the_reference_not_the_current_geometry() { + // Base fixed, apex currently above it; the reference (before a drag) has it below. + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(20.0, 15.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: b }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c, value: 25.0 }, None).unwrap(); + let mut reference = current(&sk); + reference[c] = Vec2::new(20.0, -15.0); + let d = decompose_from(&sk, &known(&sk), &reference); + assert!(d.pos[c].unwrap().dist(Vec2::new(20.0, -15.0)) < 1e-12); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert!(d.pos[c].unwrap().dist(Vec2::new(20.0, 15.0)) < 1e-12); +} + +#[test] +fn rigid_cluster_with_a_horizontal_edge_is_oriented() { + // P fixed; triangle P Q R dimensioned; Q–R horizontal. Neither Q nor R has two loci on + // placed points; the triangle turns about nothing until Q–R is level, then translates + // onto P. Q is left of R in the reference, R below P. + let mut sk = Sketch::new(); + let p = sk.add_point(Vec2::new(10.0, 10.0), None).unwrap(); + let q = sk.add_point(Vec2::new(0.0, -21.0), None).unwrap(); + let r = sk.add_point(Vec2::new(31.0, -19.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + let truth = [Vec2::new(10.0, 10.0), Vec2::new(-2.0, -20.0), Vec2::new(30.0, -20.0)]; + for (a, b) in [(0, 1), (1, 2), (0, 2)] { + let ids = [p, q, r]; + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: truth[a].dist(truth[b]) }, None).unwrap(); + } + let l = sk.add_line_pts(q, r, None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + assert!(!propagation_places_all(&sk)); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert!(d.pos[q].unwrap().dist(truth[1]) < 1e-12 && d.pos[r].unwrap().dist(truth[2]) < 1e-12, "{d:?}"); + let mut s = seeded(&sk, &d); + let rep = solve(&mut s); + assert!(rep.ok() && rep.iterations == 0, "{rep:?}"); +} + +#[test] +fn a_busy_ground_point_does_not_exhaust_the_budget() { + // 1000 points hang off the fixed hub by one distance each (they never merge); a strip + // of triangles grows from the hub and a second fixed point. The strip is placed. + let mut sk = Sketch::new(); + let hub = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let second = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + for p in [hub, second] { + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + } + for i in 0..1000 { + let t = i as f64 * 0.0063; + let p = sk.add_point(Vec2::new(-30.0 * t.cos(), -30.0 - 30.0 * t.sin()), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: hub, q: p, value: 30.0 }, None).unwrap(); + } + let mut strip = vec![hub, second]; + let mut truth = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]; + for j in 2..200 { + let t = Vec2::new(j as f64 * 5.0 + 7.0, (j % 2) as f64 * 8.0 + 4.0); + let p = sk.add_point(t + Vec2::new(0.3, -0.2), None).unwrap(); + for back in [1, 2] { + let (q, tq) = (strip[j - back], truth[j - back]); + sk.add_constraint(ConstraintKind::Distance { p: q, q: p, value: tq.dist(t) }, None).unwrap(); + } + strip.push(p); + truth.push(t); + } + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_placed(&d, &strip, &truth); +} + +/// A drag frame's decomposition stops at the frame budget (about 16 ms) where the full one goes +/// on (about 0.2 s here); the frame's solve still solves, from LM. +#[test] +fn a_drag_frame_decomposes_within_its_budget() { + let mut sk = Sketch::new(); + let hub = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let second = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: hub }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: second }, None).unwrap(); + let mut strip = vec![(hub, Vec2::new(0.0, 0.0)), (second, Vec2::new(10.0, 0.0))]; + for i in 0..1000 { + let t = i as f64 * 0.0063; + let p = sk.add_point(Vec2::new(-30.0 * t.cos(), -30.0 - 30.0 * t.sin()), None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: hub, q: p, value: 30.0 }, None).unwrap(); + let j = i + 2; + let at = Vec2::new(j as f64 * 5.0 + 7.0, (j % 2) as f64 * 8.0 + 4.0); + let p = sk.add_point(at + Vec2::new(0.3, -0.2), None).unwrap(); + for back in [1, 2] { + let (q, tq) = strip[strip.len() - back]; + sk.add_constraint(ConstraintKind::Distance { p: q, q: p, value: tq.dist(at) }, None).unwrap(); + } + strip.push((p, at)); + } + let reading = crate::loci::read(&sk, &|i| sk.points.get(i).map(|p| p.pos), &crate::solver::points_on_curves(&sk), &crate::loci::radii(&sk)); + let work = |limit: usize| Decomposition::new(&reading, &known(&sk), true, limit).work().0; + let (frame, full) = (work(FRAME_WORK), work(MAX_WORK)); + assert!(frame > FRAME_WORK / 2 && frame <= FRAME_WORK + FRAME_WORK / 100, "frame work {frame}"); + assert!(full > 10 * FRAME_WORK, "full work {full}"); + let hold = crate::Hold::default(); + assert!( + switch::without_differential(|| crate::solve_with(&mut sk.clone(), &hold, None, crate::Analysis::StatusOnly, Some(&mut Budget::frame()))) + .ok() + ); +} + +/// Wherever the budget runs out, the merge search stops within one point's scan of it: a +/// rigid triangle grows from a hub with thousands of relations, each one a candidate. +#[test] +fn growth_stops_at_the_budget() { + let mut sk = Sketch::new(); + let h = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let a = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(3.0, 8.0), None).unwrap(); + for (p, q) in [(h, a), (a, b), (b, h)] { + let d = sk.points[p].pos.dist(sk.points[q].pos); + sk.add_constraint(ConstraintKind::Distance { p, q, value: d }, None).unwrap(); + } + let n = 1000; + for i in 0..n { + let s = sk.add_point(Vec2::from_angle(i as f64 * 0.002) * 20.0, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: h, q: s, value: 20.0 }, None).unwrap(); + } + let reading = crate::loci::read(&sk, &|i| sk.points.get(i).map(|p| p.pos), &crate::solver::points_on_curves(&sk), &crate::loci::radii(&sk)); + let none = |_: usize| None; + let mut full = Graph::build(&reading, &none, MAX_WORK).unwrap(); + full.reduce(); + let total = full.work; + assert!(total > 2 * n, "work {total}"); + // One point's scan: the hub's relations, and what its candidates cost. + let scan = n + 16; + let worst = (1..total).step_by((total / 60).max(1)).map(|limit| { + let mut g = Graph::build(&reading, &none, limit).unwrap(); + g.reduce(); + g.work.saturating_sub(limit) + }); + let worst = worst.max().unwrap_or(0); + assert!(worst <= scan, "overshoot {worst} past the budget"); +} + +#[test] +fn work_is_bounded_on_a_star_of_oriented_pairs() { + // 20,000 points at a DistanceX and a DistanceY from one fixed point (the cluster cap). + let mut sk = Sketch::new(); + let n = MAX_CLUSTERS; + for i in 0..n { + let pos = Vec2::new((i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0); + push_point(&mut sk, pos); + sk.constraints.push(raw(ConstraintKind::DistanceX { p: 0, q: i + 1, value: pos.x })); + sk.constraints.push(raw(ConstraintKind::DistanceY { p: 0, q: i + 1, value: pos.y })); + } + let given = known(&sk); + let reading = crate::loci::read(&sk, &|i| sk.points.get(i).map(|p| p.pos), &crate::solver::points_on_curves(&sk), &crate::loci::radii(&sk)); + let mut g = Graph::build(&reading, &|i| given.get(i).copied().flatten(), MAX_WORK).unwrap(); + g.reduce(); + assert!(g.work < MAX_WORK / 10, "work {}", g.work); + let ground = g.cluster(g.ground.unwrap()).unwrap(); + assert_eq!(ground.pts.len(), n + 1); + assert!(ground.pts.iter().all(|(i, p)| p.dist(sk.points[*i].pos) < 1e-12)); +} + +#[test] +fn orient_tries_every_bond() { + // P fixed; the rigid body P S Q R T: R coincides with Q in it (same distances to P and + // S, same side), so the horizontal relation Q–R can't turn it; Q–T horizontal does. + let truth = [Vec2::new(0.0, 0.0), Vec2::new(12.0, 0.0), Vec2::new(6.0, 8.0), Vec2::new(6.0, 8.0), Vec2::new(20.0, 8.0)]; + let turned = |v: Vec2| Vec2::new(v.x * 0.98 - v.y * 0.17, v.x * 0.17 + v.y * 0.98); + let mut sk = Sketch::new(); + let ids: Vec = truth + .iter() + .enumerate() + .map(|(i, t)| sk.add_point(turned(*t) + if i == 3 { Vec2::new(0.2, -0.1) } else { Vec2::ZERO }, None).unwrap()) + .collect(); + // [P, S, Q, R, T] + sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap(); + for (a, b) in [(0, 1), (0, 2), (1, 2), (0, 3), (1, 3), (0, 4), (1, 4)] { + sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: truth[a].dist(truth[b]) }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::HorizontalPoints { p: ids[2], q: ids[3] }, None).unwrap(); + sk.add_constraint(ConstraintKind::HorizontalPoints { p: ids[2], q: ids[4] }, None).unwrap(); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_placed(&d, &ids, &truth); +} + +#[test] +fn signs_come_from_the_constrained_point_not_its_coincident_group() { + // p fixed; q coincident with p but drawn 20 mm right of it; r 5 mm from q along x and + // level with it, drawn left of q (right of p). r goes to q's left. + let mut sk = Sketch::new(); + let p = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let q = sk.add_point(Vec2::new(20.0, 0.0), None).unwrap(); + let r = sk.add_point(Vec2::new(10.0, 1.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p, q }, None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceX { p: q, q: r, value: 5.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::HorizontalPoints { p: q, q: r }, None).unwrap(); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_eq!(d.pos[q], Some(Vec2::ZERO)); + assert_eq!(d.pos[r], Some(Vec2::new(-5.0, 0.0))); +} + +#[test] +fn triangle_turn_comes_from_the_constrained_point() { + // C (drawn below the base) is coincident with D (drawn above); the distances are written + // on D, so the apex goes above. + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(20.0, -15.0), None).unwrap(); + let d = sk.add_point(Vec2::new(20.0, 15.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: b }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: c, q: d }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: d, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: d, value: 25.0 }, None).unwrap(); + let out = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_placed(&out, &[c, d], &[Vec2::new(20.0, 15.0); 2]); +} + +#[test] +fn hinge_side_comes_from_the_constrained_pivot() { + // F and E fixed; G coincident with F but drawn at (−40, 0); the rigid triangle G P Q is + // written on G and turns about F until Q is level with E. Q is drawn right of G (left + // of F), so it lands right of the pin. + let mut sk = Sketch::new(); + let f = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let e = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap(); + let g = sk.add_point(Vec2::new(-40.0, 0.0), None).unwrap(); + let p = sk.add_point(Vec2::new(-30.0, 10.0), None).unwrap(); + let q = sk.add_point(Vec2::new(-20.0, 3.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: f }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: e }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: f, q: g }, None).unwrap(); + for (a, b) in [(g, p), (g, q), (p, q)] { + let value = sk.points[a].pos.dist(sk.points[b].pos); + sk.add_constraint(ConstraintKind::Distance { p: a, q: b, value }, None).unwrap(); + } + sk.add_constraint(ConstraintKind::HorizontalPoints { p: e, q }, None).unwrap(); + let out = decompose_from(&sk, &known(&sk), ¤t(&sk)); + let radius = sk.points[g].pos.dist(sk.points[q].pos); + assert_placed(&out, &[q], &[Vec2::new(radius, 0.0)]); +} + +#[test] +fn zero_distance_joins_points() { + // C and C2 at distance zero carry one side each of the triangle on the fixed base. + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(20.0, 10.0), None).unwrap(); + let c2 = sk.add_point(Vec2::new(21.0, 11.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: b }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: c, q: c2, value: 0.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 25.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: b, q: c2, value: 25.0 }, None).unwrap(); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_eq!(d.pos[c], d.pos[c2]); + assert!(d.pos[c].is_some_and(|v| v.dist(Vec2::new(20.0, 15.0)) < 1e-12), "{d:?}"); +} + +#[test] +fn coincident_points_and_unreachable_triangles() { + // C is coincident with C2, which carries the second distance; the triangle closes. + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(20.0, 10.0), None).unwrap(); + let c2 = sk.add_point(Vec2::new(21.0, 11.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: b }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: c, q: c2 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Distance { p: a, q: c, value: 25.0 }, None).unwrap(); + let id = sk.add_constraint(ConstraintKind::Distance { p: b, q: c2, value: 25.0 }, None).unwrap(); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert_eq!(d.pos[c], d.pos[c2]); + assert!(d.pos[c].unwrap().dist(Vec2::new(20.0, 15.0)) < 1e-12); + // Sides that can't meet: nothing placed, the solver reports the conflict. + sk.constraints.iter_mut().find(|k| k.id == id).unwrap().kind.set_value(5.0); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert!(d.pos[c].is_none() && d.pos[c2].is_none()); +} + +#[test] +fn hostile_input_never_panics() { + let mut sk = Sketch::new(); + let p = sk.add_point(Vec2::new(1.0, 1.0), None).unwrap(); + let q = sk.add_point(Vec2::new(5.0, 1.0), None).unwrap(); + let r = sk.add_point(Vec2::new(3.0, 4.0), None).unwrap(); + for kind in [ + ConstraintKind::Distance { p, q: 999, value: 1.0 }, + ConstraintKind::Distance { p, q, value: f64::NAN }, + ConstraintKind::Distance { p, q: r, value: f64::INFINITY }, + ConstraintKind::Distance { p: q, q: r, value: -3.0 }, + ConstraintKind::Distance { p, q: p, value: 2.0 }, + ConstraintKind::Length { l: 77, value: 1.0 }, + ConstraintKind::Horizontal { l: 3 }, + ConstraintKind::DistanceX { p, q, value: 1e300 }, + ConstraintKind::DistanceY { p, q, value: 1e300 }, + ConstraintKind::Coincident { p: 999, q: p }, + ConstraintKind::Coincident { p, q }, + ConstraintKind::Coincident { p: q, q: p }, + ConstraintKind::Fix { p: 12345 }, + ] { + sk.constraints.push(raw(kind)); + } + let n = sk.points.len(); + let nan = Vec2::new(f64::NAN, f64::INFINITY); + for known in [vec![], vec![Some(nan); n], vec![Some(Vec2::ZERO); n + 5], vec![None, Some(Vec2::new(1e300, -1e300))]] { + for reference in [vec![], vec![nan; n], vec![Vec2::ZERO; 1]] { + let d = decompose_from(&sk, &known, &reference); + assert_eq!(d.pos.len(), n); + assert!(d.pos.iter().flatten().all(|v| v.is_finite())); + } + } + // Every point coincident with its neighbour, all on the origin: one node, nothing to do. + let mut chain = Sketch::new(); + for i in 0..200 { + chain.add_point(Vec2::new(i as f64, 0.0), None).unwrap(); + } + for i in 1..chain.points.len() { + chain.constraints.push(raw(ConstraintKind::Coincident { p: i, q: i - 1 })); + } + let d = decompose_from(&chain, &known(&chain), &[]); + assert!(d.pos.iter().all(|p| *p == Some(Vec2::ZERO))); +} + +/// Both readers of the constraints (propagation and decomposition) share one validation: +/// a dimension beyond the sketch's coordinate range is read by neither. +#[test] +fn oversized_dimensions_are_read_by_neither() { + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(5.0, 1.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: a }, None).unwrap(); + sk.constraints.push(raw(ConstraintKind::Distance { p: a, q: b, value: 1e15 })); + let reading = crate::loci::read(&sk, &|i| sk.points.get(i).map(|p| p.pos), &crate::solver::points_on_curves(&sk), &crate::loci::radii(&sk)); + assert_eq!(reading.rels.len(), 0); + let given = known(&sk); + let g = Graph::build(&reading, &|i| given.get(i).copied().flatten(), MAX_WORK).unwrap(); + assert_eq!(g.clusters.len(), 1, "the ground only"); +} + +#[test] +fn large_inputs_are_capped() { + // More starting clusters than the cap: left to the solver untouched. + let mut sk = Sketch::new(); + let n = 210; + for i in 0..n { + push_point(&mut sk, Vec2::new(i as f64, (i * i % 17) as f64)); + } + for i in 0..n { + for j in i + 1..n { + sk.constraints.push(raw(ConstraintKind::Distance { p: i, q: j, value: 1.0 + (i + j) as f64 })); + } + } + assert!(sk.constraints.len() > MAX_CLUSTERS); + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + assert!(d.pos.iter().all(Option::is_none), "no graph, nothing placed"); + + // A dense complete graph under the cap finishes (the merge loop is bounded). + let mut sk = Sketch::new(); + let pts: Vec = (0..60).map(|i| Vec2::new((i as f64 * 1.7).sin() * 50.0, (i as f64 * 2.3).cos() * 50.0)).collect(); + let ids: Vec = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap(); + sk.add_constraint(ConstraintKind::Fix { p: ids[1] }, None).unwrap(); + for i in 0..ids.len() { + for j in i + 1..ids.len() { + sk.add_constraint(ConstraintKind::Distance { p: ids[i], q: ids[j], value: pts[i].dist(pts[j]) }, None).unwrap(); + } + } + let d = decompose_from(&sk, &known(&sk), ¤t(&sk)); + for (i, id) in ids.iter().enumerate() { + assert!(d.pos[*id].unwrap().dist(pts[i]) < 1e-6); + } +} + +/// Measurement, not a gate: per family, through `solve`, how often a sub-triangle mirrors and +/// how often the solve fails, with the numerical solver alone, with the propagation seed (the +/// solver before the decomposition was part of the seed) and with the full seed. `gap`: cases +/// propagation cannot place (see [`propagation_places_all`]) that the solve mirrors. +#[test] +#[ignore] +fn gap_counter() { + let mut rng = Lcg(2026); + let n = 500; + let mut families: Vec<(String, Box Option>)> = Vec::new(); + for datum in DATUMS { + families.push(( + format!("bracket {datum:?}"), + Box::new(move |r: &mut Lcg| { + let scale = if r.next() < 0.5 { 1.0 } else { 10.0 }; + bracket(r, datum, scale) + }), + )); + } + for k in [6, 8, 12] { + families.push((format!("fan {k}-gon 0.5–2.5×"), Box::new(move |r: &mut Lcg| fan(r, k, 0.5, 2.5, true)))); + families.push((format!("floating fan {k}-gon 0.5–2.5×"), Box::new(move |r: &mut Lcg| fan(r, k, 0.5, 2.5, false)))); + } + families.push(("triconnected linkage 0.8–1.25×".into(), Box::new(linkage))); + println!( + "family | cases | not propagatable | LM alone: failed / mirrored | propagation seed: failed / mirrored / gap | full seed: failed / mirrored / gap / iterations>0 | full vs LM alone: worse / better" + ); + for (name, make) in &mut families { + let (mut unreach, mut iterated, mut worse, mut better) = (0, 0, 0, 0); + let mut tally = [Tally::default(); 3]; + for _ in 0..n { + let case = sample(&mut rng, &mut **make); + let before = signature(&case.checks, ¤t(&case.sk)); + let reachable = propagation_places_all(&case.sk); + unreach += usize::from(!reachable); + let solved = Mode::ALL.map(|mode| { + let mut s = case.sk.clone(); + let rep = match mode { + Mode::LmAlone => switch::without_seed(|| solve(&mut s)), + Mode::Propagation => switch::without_decomposition(|| solve(&mut s)), + Mode::Full => solve(&mut s), + }; + let mirrored = rep.ok() && signature(&case.checks, ¤t(&s)) != before; + (rep, mirrored) + }); + for (t, (rep, mirrored)) in tally.iter_mut().zip(&solved) { + t.failed += usize::from(!rep.ok()); + t.mirrored += usize::from(*mirrored); + t.gap += usize::from(*mirrored && !reachable); + } + let [(lm, _), _, (full, _)] = &solved; + iterated += usize::from(full.iterations > 0); + worse += usize::from(lm.ok() && !full.ok()); + better += usize::from(!lm.ok() && full.ok()); + } + let [a, p, f] = tally; + println!( + "{name} | {n} | {unreach} | {} / {} | {} / {} / {} | {} / {} / {} / {iterated} | {worse} / {better}", + a.failed, a.mirrored, p.failed, p.mirrored, p.gap, f.failed, f.mirrored, f.gap + ); + } +} + +/// How `gap_counter` solves: LM from the sketch's positions, the propagation seed only, the +/// full seed. +#[derive(Clone, Copy)] +enum Mode { + LmAlone, + Propagation, + Full, +} + +impl Mode { + const ALL: [Mode; 3] = [Mode::LmAlone, Mode::Propagation, Mode::Full]; +} + +#[derive(Clone, Copy, Default)] +struct Tally { + failed: usize, + mirrored: usize, + gap: usize, +} diff --git a/crates/sketch/src/dims.rs b/crates/sketch/src/dims.rs index 937e1314..63cdc9ba 100644 --- a/crates/sketch/src/dims.rs +++ b/crates/sketch/src/dims.rs @@ -11,6 +11,7 @@ use solvecraft_geom::Vec2; use crate::model::{ConstraintKind, CurveKind, Sketch}; +use crate::union_find::{find, union}; /// What a dimension measures, in a form that can be drawn. #[derive(Clone, Copy, Debug, PartialEq)] @@ -138,30 +139,14 @@ pub fn encode_text(f: &DimFrame, at: Vec2) -> Vec2 { pub fn chain_centres(sk: &Sketch) -> Vec { let n = sk.points.len(); let mut parent: Vec = (0..n).collect(); - fn root(p: &mut [usize], mut i: usize) -> usize { - while let Some(&q) = p.get(i) { - if q == i { - break; - } - let g = p.get(q).copied().unwrap_or(q); - if let Some(s) = p.get_mut(i) { - *s = g; - } - i = q; - } - i - } for c in &sk.curves { let ids = c.kind.point_ids(); for w in ids.windows(2) { - let (a, b) = (root(&mut parent, w[0]), root(&mut parent, w[1])); - if let Some(s) = parent.get_mut(a) { - *s = b; - } + union(&mut parent, w[0], w[1]); } } let mut sum = vec![(Vec2::ZERO, 0usize); n]; - let roots: Vec = (0..n).map(|i| root(&mut parent, i)).collect(); + let roots: Vec = (0..n).map(|i| find(&mut parent, i)).collect(); for (i, r) in roots.iter().enumerate() { if let (Some(s), Some(p)) = (sum.get_mut(*r), sk.points.get(i)) { s.0 += p.pos; diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 048033dd..5b86ccac 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -5,14 +5,25 @@ //! The solver is our own clean-room implementation (residual functions per constraint, local //! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by //! Gaussian elimination). See `plan/architecture.md` §sketch. +//! +//! Before iterating, a constructive seed places every point that two loci on already placed +//! points pin down (circles, rows and columns, lines through placed points, offsets from +//! placed lines, tangency, midpoints, mirrored points, coincidence), on the branch that keeps +//! the orientation the point had before the solve, so dimension edits do not fold the sketch +//! into a mirrored solution or flip an arc out of its corner. The seed only moves the starting +//! point: the iteration still verifies every constraint and the rank analysis still decides +//! the degrees of freedom. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod construct; mod curves; +mod decompose; mod dims; mod freeform_offset; mod linalg; mod link; +mod loci; mod model; mod offset; mod offset_topology; @@ -21,12 +32,14 @@ mod profiles; mod shape; mod solver; mod text; +mod union_find; mod wire; pub use curves::{ MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent, spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, }; +pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; @@ -36,10 +49,18 @@ pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRole pub use periodic::periodic_beziers; pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves}; pub use shape::{Shape, intersections}; -pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding}; +pub use solver::{ + Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with, +}; pub use text::{MAX_TEXT_CHARS, text_geometry}; pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; +#[cfg(test)] +mod construct_rounds_tests; +#[cfg(test)] +mod construct_tests; +#[cfg(test)] +mod continuation_tests; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index cf20eb60..459ed18e 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::{Vec2, Vec3}; -use crate::model::{Curve, CurveKind, Result, Sketch, SketchError}; +use crate::model::{Curve, CurveKind, Result, Sketch, SketchError, in_range, radius_in_range}; /// Points of one link closer than this are merged into one point. const MERGE_TOL: f64 = 1e-7; @@ -154,16 +154,14 @@ pub enum LinkGeom { impl LinkGeom { fn finite(&self) -> bool { - let ok = |p: &Vec2| p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; + let ok = |p: &Vec2| in_range(*p); match self { LinkGeom::Point(p) => ok(p), LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL, - LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9, + LinkGeom::Circle(c, r) => ok(c) && radius_in_range(*r), LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL, LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6, - LinkGeom::Ellipse { c, major, minor } => { - ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9 - } + LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor), LinkGeom::Spline { pts, control, degree, periodic } => { (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) diff --git a/crates/sketch/src/loci.rs b/crates/sketch/src/loci.rs new file mode 100644 index 00000000..bc9a37ef --- /dev/null +++ b/crates/sketch/src/loci.rs @@ -0,0 +1,1363 @@ +//! What the constructive seed reads from a sketch and how it intersects it, shared by the +//! point-by-point placement and the cluster decomposition: the relations driving constraints +//! set between points, their loci, circle ∩ circle, circle ∩ line, line ∩ line, which of two +//! mirror-image intersections keeps the orientation of a reference layout (Owen 1991, §2.4.3 +//! and §5), and where two or more loci put a point ([`place`]). +//! +//! [`read`] turns the driving constraints into +//! +//! - **relations between two points** ([`Rel`]): coincidence, distance, line length (also +//! through equal lines), horizontal and vertical (also through parallel, perpendicular and +//! collinear lines, and axis-aligned line angles), horizontal and vertical distances; the +//! radius of an arc (a radius or diameter, also through equal arcs and circles) as distances +//! from its centre to its ends, a point on a circle of known radius, concentric centres, +//! tangent circles of known radii (external or internal as in the reference), the centre +//! distance of a slot of known radii; and, on a horizontal or vertical line, a point on it, +//! at a distance from it, its midpoint, a point mirrored across it and a tangent circle of +//! known radius (its centre at the radius from the line, above or beside the touch point). +//! These are invariant under translation (and rotation unless along an axis), so the +//! decomposition's clusters are built on them; +//! - **ties** between more points ([`Tie`]), read where a placement has the points they are +//! anchored on: a point on a line or at a distance from it (a tangent circle's centre, with a +//! known or arc radius), the radius to a touch point normal to the line, a midpoint, a point +//! mirrored across a line, equal lengths, an arc's ends at one distance from its centre (and +//! the centre on their bisector), and a line along a direction derived from a line angle, an +//! angle to another line, or parallel or perpendicular to it. +//! +//! Signs no residual keeps (horizontal or vertical distances, distances from a line, which +//! side of a line a tangent circle is on, internal or external tangency) come from the +//! reference layout. Ellipses, splines, conics, arc sweeps and lengths, and curvature +//! continuity are not read: the solver handles them. +//! +//! Tolerances are relative to the loci's own size plus the rounding of the coordinates they +//! are computed from, so a construction far from the origin behaves like the same one near it. + +use solvecraft_geom::Vec2; + +use crate::model::{ConstraintKind, CurveKind, MAX_COORD, Sketch, in_range, radius_in_range}; +use crate::union_find::{find, union}; + +/// Two intersections closer than this (relative to the size of the loci: radii, centre +/// distance) are one touching intersection. +pub(crate) const TANGENT: f64 = 1e-6; +/// A point this close to the line that mirrors two candidates (relative to the size of the +/// loci) is on neither side of it: its branch is unknown. +const ON_MIRROR: f64 = 1e-9; + +/// A distance shorter than this (mm) joins two points into one. +const ZERO_LENGTH: f64 = 1e-9; + +/// Loci considered per point; more only add pairs that confirm the same position. +pub(crate) const MAX_LOCI: usize = 16; +/// Direction sources read per line. +const MAX_SOURCES: usize = 4; +/// How many lines deep a direction is derived through angles between lines. +const DEPTH: usize = 3; +/// Lines whose ties one placed point wakes through direction dependencies. +const MAX_WOKEN_LINES: usize = 16; + +/// Largest absolute coordinate. +pub(crate) fn mag(v: Vec2) -> f64 { + v.x.abs().max(v.y.abs()) +} + +/// Rounding error of a position computed from coordinates up to `m` (mm), with headroom for +/// the few operations of an intersection. Independent of the loci's own size. +fn rounding(m: f64) -> f64 { + 8.0 * f64::EPSILON * m +} + +/// A length tolerance: `rel` of the loci's own `size` (at least 1 mm), plus the rounding of +/// coordinates up to `coords`. +pub(crate) fn tol(rel: f64, size: f64, coords: f64) -> f64 { + rel * size.max(1.0) + rounding(coords) +} + +/// A point `miss` away from a locus of `size` (coordinates up to `coords`) lies on it. +pub(crate) fn within(miss: f64, size: f64, coords: f64) -> bool { + miss <= tol(TANGENT, size, coords) +} + +/// `v` is clear of zero: farther from it than the `ON_MIRROR` share of `size` plus the +/// rounding of coordinates up to `coords`. +fn clear_of_zero(v: f64, size: f64, coords: f64) -> bool { + v.abs() > tol(ON_MIRROR, size, coords) +} + +/// The sign of an offset `delta` between two points (coordinates up to `coords`) that an +/// offset of `size` keeps; None when it is too close to zero to tell. +pub(crate) fn side(delta: f64, size: f64, coords: f64) -> Option { + clear_of_zero(delta, size, coords).then(|| delta.signum()) +} + +/// How a driving constraint relates two points, as seen from either of them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Rel { + Same, + SameX, + SameY, + Dist(f64), + /// |q.x − p.x| = d. + OffsetX(f64), + /// |q.y − p.y| = d. + OffsetY(f64), +} + +impl Rel { + /// Sort key, so that a result never depends on the order of the constraints. + pub(crate) fn key(self) -> (u8, u64) { + match self { + Rel::Same => (0, 0), + Rel::SameX => (1, 0), + Rel::SameY => (2, 0), + Rel::Dist(d) => (3, d.to_bits()), + Rel::OffsetX(d) => (4, d.to_bits()), + Rel::OffsetY(d) => (5, d.to_bits()), + } + } + + /// The two points are one: coincident, or at a distance shorter than [`ZERO_LENGTH`]. + pub(crate) fn joins(self) -> bool { + match self { + Rel::Same => true, + Rel::Dist(d) => d < ZERO_LENGTH, + _ => false, + } + } + + /// Along an axis of the sketch: holds only in a frame oriented as the sketch's. + fn along_axis(self) -> bool { + matches!(self, Rel::SameX | Rel::SameY | Rel::OffsetX(_) | Rel::OffsetY(_)) + } + + /// The dimension's value (zero for the relations without one). + pub(crate) fn size(self) -> f64 { + match self { + Rel::Same | Rel::SameX | Rel::SameY => 0.0, + Rel::Dist(d) | Rel::OffsetX(d) | Rel::OffsetY(d) => d, + } + } + + /// Does it hold between `p` and `q`, within the tolerance of a locus of size `scale` + /// (at least the dimension's own)? + pub(crate) fn holds(self, p: Vec2, q: Vec2, scale: f64) -> bool { + let d = q - p; + let miss = match self { + Rel::Same => d.len(), + Rel::SameX => d.x.abs(), + Rel::SameY => d.y.abs(), + Rel::Dist(v) => (d.len() - v).abs(), + Rel::OffsetX(v) => (d.x.abs() - v).abs(), + Rel::OffsetY(v) => (d.y.abs() - v).abs(), + }; + within(miss, self.size().max(scale), mag(p).max(mag(q))) + } + + /// The locus of a point so related to a point at `at`; `side` is the side of an offset + /// (±1, 0 when unknown). None for a coincidence, or an offset whose side is unknown. + fn locus(self, at: Vec2, side: f64) -> Option { + Some(match self { + Rel::Same => return None, + Rel::SameX => Locus::Line { o: at, d: Vec2::Y }, + Rel::SameY => Locus::Line { o: at, d: Vec2::X }, + Rel::Dist(r) => Locus::Circle { c: at, r }, + // A zero offset needs no side; any other offset needs the side the point was on. + Rel::OffsetX(d) if d == 0.0 || side != 0.0 => Locus::Line { o: at + Vec2::X * (side * d), d: Vec2::Y }, + Rel::OffsetY(d) if d == 0.0 || side != 0.0 => Locus::Line { o: at + Vec2::Y * (side * d), d: Vec2::X }, + Rel::OffsetX(_) | Rel::OffsetY(_) => return None, + }) + } + + /// Which side of the anchor `p` was on before, along x or y (0 when unknown or level). + fn offset_side(self, p: Option, anchor: Option) -> f64 { + let (Some(p), Some(a)) = (p, anchor) else { return 0.0 }; + let (delta, d) = match self { + Rel::OffsetX(d) => (p.x - a.x, d), + Rel::OffsetY(d) => (p.y - a.y, d), + _ => return 0.0, + }; + side(delta, d, mag(p).max(mag(a))).unwrap_or(0.0) + } +} + +/// A relation between two points, as stored at each of them. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Edge { + pub p: usize, + pub q: usize, + pub rel: Rel, +} + +impl Edge { + pub(crate) fn other(&self, i: usize) -> usize { + if self.p == i { self.q } else { self.p } + } +} + +/// Where a point can be. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Locus { + Circle { + c: Vec2, + r: f64, + }, + /// The line through `o` along the unit direction `d`. + Line { + o: Vec2, + d: Vec2, + }, +} + +impl Locus { + /// How far `x` is from the locus. + pub(crate) fn miss(&self, x: Vec2) -> f64 { + match *self { + Locus::Circle { c, r } => (x.dist(c) - r).abs(), + Locus::Line { o, d } => d.cross(x - o).abs(), + } + } + /// The locus's own size (a line has none). + pub(crate) fn extent(&self) -> f64 { + match *self { + Locus::Circle { r, .. } => r, + Locus::Line { .. } => 0.0, + } + } + /// A point of the locus's definition (how far from the origin it is sets the rounding). + fn origin(&self) -> Vec2 { + match *self { + Locus::Circle { c, .. } => c, + Locus::Line { o, .. } => o, + } + } + /// A locus the arithmetic can use: finite, in range, a unit direction or a radius. + fn checked(self) -> Option { + let ok = match self { + Locus::Circle { c, r } => in_range(c) && r.is_finite() && (0.0..=MAX_COORD).contains(&r), + Locus::Line { o, d } => in_range(o) && d.is_finite() && (d.len() - 1.0).abs() < 1e-9, + }; + ok.then_some(self) + } +} + +/// The intersection of two loci. +pub(crate) enum Meet { + /// Parallel lines or concentric circles: no isolated intersection. + Degenerate, + /// The loci do not meet. + Empty, + One { + at: Vec2, + transversal: bool, + }, + /// Mirror images across the line of [`mirror`]: `pos` on its left, `neg` on its right. + Two { + pos: Vec2, + neg: Vec2, + }, +} + +/// The line (point, unit direction) across which the two intersections of `l1` and `l2` mirror +/// each other. +fn mirror(l1: &Locus, l2: &Locus) -> Option<(Vec2, Vec2)> { + match (*l1, *l2) { + (Locus::Circle { c: c1, .. }, Locus::Circle { c: c2, .. }) => Some((c1, (c2 - c1).normalized()?)), + (Locus::Circle { c, .. }, Locus::Line { d, .. }) | (Locus::Line { d, .. }, Locus::Circle { c, .. }) => Some((c, Vec2::new(d.y, -d.x))), + (Locus::Line { .. }, Locus::Line { .. }) => None, + } +} + +pub(crate) fn meet(l1: &Locus, l2: &Locus) -> Meet { + let noise = rounding(mag(l1.origin()).max(mag(l2.origin()))); + // The candidates are mid ± h·n (n to the left of the mirror line). h² is a difference of + // squares of lengths up to `s`: touching when within the band of its size and of the + // rounding of the positions. + let chord = |mid: Vec2, h2: f64, n: Vec2, s: f64| { + let band = (TANGENT * s).powi(2) + 2.0 * s * noise; + if !(h2 >= -band) { + Meet::Empty + } else if h2 <= band { + Meet::One { at: mid, transversal: false } + } else { + let h = h2.sqrt(); + Meet::Two { pos: mid + n * h, neg: mid - n * h } + } + }; + match (*l1, *l2) { + (Locus::Circle { c: c1, r: r1 }, Locus::Circle { c: c2, r: r2 }) => { + let d = c2 - c1; + let dd = d.len(); + if !(dd > tol(TANGENT, r1.max(r2), mag(c1).max(mag(c2)))) { + return Meet::Degenerate; + } + let u = d / dd; + let along = (r1 * r1 - r2 * r2 + dd * dd) / (2.0 * dd); + chord(c1 + u * along, r1 * r1 - along * along, u.perp(), r1.max(r2).max(dd).max(1.0)) + } + (Locus::Circle { c, r }, Locus::Line { o, d }) | (Locus::Line { o, d }, Locus::Circle { c, r }) => { + let foot = o + d * (c - o).dot(d); + let off = (c - foot).len2(); + // The mirror line runs along -perp(d), so d points to its left. + chord(foot, r * r - off, d, r.max(off.sqrt()).max(1.0)) + } + (Locus::Line { o: o1, d: d1 }, Locus::Line { o: o2, d: d2 }) => { + let s = d1.cross(d2); + if s.abs() < 1e-12 { + return Meet::Degenerate; + } + Meet::One { at: o1 + d1 * ((o2 - o1).cross(d2) / s), transversal: true } + } + } +} + +/// Which side of the mirror line of `l1` and `l2` the point `p` is on: positive on its left. +/// None when unknown or on the line. Built from the loci as they were in the reference layout, +/// it tells which of two intersections keeps the orientation `p` had there. +pub(crate) fn branch(l1: Option<&Locus>, l2: Option<&Locus>, p: Option) -> Option { + let (l1, l2, p) = (l1?, l2?, p?); + let (o, u) = mirror(l1, l2)?; + let side = u.cross(p - o); + clear_of_zero(side, l1.extent().max(l2.extent()), mag(p).max(mag(o))).then_some(side) +} + +/// The crossing of the loci `now` that keeps the orientation `p` has relative to `then` (the +/// same loci in the reference layout). None when the loci do not cross (a touching +/// intersection pins nothing down well) or the side is unknown. +pub(crate) fn crossing(now: (Locus, Locus), then: Option<(Locus, Locus)>, p: Option) -> Option { + match meet(&now.0, &now.1) { + Meet::Two { pos, neg } => { + let (t1, t2) = then.unzip(); + branch(t1.as_ref(), t2.as_ref(), p).map(|side| if side > 0.0 { pos } else { neg }) + } + Meet::One { at, transversal: true } => Some(at), + Meet::One { transversal: false, .. } | Meet::Empty | Meet::Degenerate => None, + } +} + +/// What one relation says about a point. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Shape { + /// The point is here. + At(Vec2), + /// The point is on `locus`; `then` is the same locus in the reference layout (it picks the + /// branch of an intersection). + On { locus: Locus, then: Option }, +} + +/// One relation of a point to placed points; `anchor` is the placed point it is anchored on +/// (the lowest, for a relation anchored on several). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Item { + pub anchor: usize, + pub shape: Shape, +} + +/// Where [`place`] puts a point: at the intersection of two loci (or at a point) anchored on +/// `a` ≤ `b`; `transversal` when the loci cross. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Fix { + pub at: Vec2, + pub a: usize, + pub b: usize, + pub transversal: bool, +} + +/// Where the relations `items` put a point that was at `p_before`: every pair of loci is +/// intersected, and a candidate must also lie on every other locus and point. A transversal +/// placement is preferred to a touching one, then the one from the lowest anchors, so the +/// result does not depend on the order of the relations. None when no pair pins the point +/// down, or the relations conflict. +pub(crate) fn place(items: &[Item], p_before: Option) -> Option { + let points: Vec<(usize, Vec2)> = items + .iter() + .filter_map(|i| match i.shape { + Shape::At(x) => Some((i.anchor, x)), + Shape::On { .. } => None, + }) + .collect(); + let loci: Vec<(usize, Locus, Option)> = items + .iter() + .filter_map(|i| match i.shape { + Shape::On { locus, then } => Some((i.anchor, locus, then)), + Shape::At(_) => None, + }) + .collect(); + // Does `x` lie on every point and locus except the ones it was built from? + let fits = |x: Vec2, from_point: Option, from_loci: Option<(usize, usize)>, scale: f64| { + let ok = |miss: f64, size: f64| within(miss, size.max(scale), mag(x)); + points.iter().enumerate().all(|(k, (_, q))| Some(k) == from_point || ok(x.dist(*q), 0.0)) + && loci.iter().enumerate().all(|(k, (_, l, _))| from_loci.is_some_and(|(i, j)| k == i || k == j) || ok(l.miss(x), l.extent())) + }; + // Best first: transversal, then the lowest anchors. + let mut best: Option<((bool, (usize, usize)), Vec2)> = None; + let mut offer = |transversal: bool, a: usize, b: usize, at: Vec2| { + let key = (!transversal, (a.min(b), a.max(b))); + if in_range(at) && best.is_none_or(|(k, _)| key < k) { + best = Some((key, at)); + } + }; + for (k, (anchor, at)) in points.iter().enumerate() { + if fits(*at, Some(k), None, 0.0) { + offer(true, *anchor, *anchor, *at); + } + } + for (i, (a1, l1, t1)) in loci.iter().enumerate() { + for (j, (a2, l2, t2)) in loci.iter().enumerate().skip(i + 1) { + let scale = l1.extent().max(l2.extent()); + match meet(l1, l2) { + Meet::Degenerate => {} + // The constraints conflict here: the solver reports it. + Meet::Empty => return None, + Meet::One { at, transversal } => { + if fits(at, None, Some((i, j)), scale) { + offer(transversal, *a1, *a2, at); + } + } + Meet::Two { pos: left, neg: right } => { + let at = match (fits(left, None, Some((i, j)), scale), fits(right, None, Some((i, j)), scale)) { + (true, false) => Some(left), + (false, true) => Some(right), + (true, true) => branch(t1.as_ref(), t2.as_ref(), p_before).map(|side| if side > 0.0 { left } else { right }), + (false, false) => None, + }; + if let Some(at) = at { + offer(true, *a1, *a2, at); + } + } + } + } + } + best.map(|((touching, (a, b)), at)| Fix { at, a, b, transversal: !touching }) +} + +/// Turn `d` by `by` radians. +fn turned(d: Vec2, by: f64) -> Vec2 { + let (s, c) = by.sin_cos(); + Vec2::new(d.x * c - d.y * s, d.x * s + d.y * c) +} + +/// `x` mirrored across the line through `o` along the unit direction `d`. +fn reflected(x: Vec2, o: Vec2, d: Vec2) -> Vec2 { + let w = x - o; + o + d * (2.0 * w.dot(d)) - w +} + +/// The other one of `x` and `y` when `t` is one of them. +fn partner(t: usize, x: usize, y: usize) -> Option { + if t == x { + Some(y) + } else if t == y { + Some(x) + } else { + None + } +} + +/// The readings of one value: the lowest and highest read. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Span { + pub lo: f64, + pub hi: f64, +} + +impl Span { + pub(crate) fn add(span: &mut Option, v: f64) { + *span = Some(span.map_or(Span { lo: v, hi: v }, |s| Span { lo: s.lo.min(v), hi: s.hi.max(v) })); + } + /// The value the readings agree on; None when they disagree. + pub(crate) fn agreed(self) -> Option { + (self.hi - self.lo <= tol(TANGENT, self.hi.abs(), 0.0)).then_some(self.lo) + } +} + +/// Where a line's direction comes from, besides its two placed ends. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Src { + /// Fixed in the sketch's frame (horizontal, vertical, a line angle): only in a frame + /// oriented as the sketch's. + Fixed(Vec2), + /// Line `from`'s direction turned by `by` (an angle between lines; for parallel and + /// perpendicular lines the turn of the reference layout). + Turn { from: usize, by: f64 }, +} + +impl Src { + fn key(&self) -> (u8, usize, u64, u64) { + match *self { + Src::Fixed(d) => (0, 0, d.x.to_bits(), d.y.to_bits()), + Src::Turn { from, by } => (1, from, by.to_bits(), 0), + } + } +} + +/// A length: a value, or the distance between two points once both are placed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Len { + Value(f64), + Between(usize, usize), +} + +impl Len { + fn value(self, pos: &dyn Fn(usize) -> Option) -> Option { + match self { + Len::Value(v) => Some(v), + Len::Between(a, b) => Some(pos(a)?.dist(pos(b)?)), + } + .filter(|v| v.is_finite() && *v <= MAX_COORD) + } +} + +/// A relation between more than two points (lines `l` by curve index). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Tie { + /// `p` lies on line `l` (or, for an end of `l`, `l` runs along its known direction). + OnLine { + p: usize, + l: usize, + }, + /// `p` is `len` from line `l`, on its side in the reference. + Offset { + p: usize, + l: usize, + len: Len, + }, + /// `p − e` is normal to line `l` (the radius to a touch point; a point and its mirror). + Normal { + p: usize, + e: usize, + l: usize, + }, + Midpoint { + p: usize, + a: usize, + b: usize, + }, + /// `q` mirrors `p` across line `l`. + Mirror { + p: usize, + q: usize, + l: usize, + }, + /// |q1 − p1| = |q2 − p2|. + SameLength { + p1: usize, + q1: usize, + p2: usize, + q2: usize, + }, + /// |b − c| = |a − c| (an arc's ends; a point on an arc). + Equidistant { + c: usize, + a: usize, + b: usize, + }, +} + +impl Tie { + fn key(&self) -> [u64; 6] { + let u = |i: usize| i as u64; + let len = |l: Len| match l { + Len::Value(v) => (0, v.to_bits(), 0), + Len::Between(a, b) => (1, u(a), u(b)), + }; + match *self { + Tie::OnLine { p, l } => [0, u(p), u(l), 0, 0, 0], + Tie::Offset { p, l, len: n } => { + let (k, a, b) = len(n); + [1, u(p), u(l), k, a, b] + } + Tie::Normal { p, e, l } => [2, u(p), u(e), u(l), 0, 0], + Tie::Midpoint { p, a, b } => [3, u(p), u(a), u(b), 0, 0], + Tie::Mirror { p, q, l } => [4, u(p), u(q), u(l), 0, 0], + Tie::SameLength { p1, q1, p2, q2 } => [5, u(p1), u(q1), u(p2), u(q2), 0], + Tie::Equidistant { c, a, b } => [6, u(c), u(a), u(b), 0, 0], + } + } + + /// The line whose direction it reads. + fn line(&self) -> Option { + match *self { + Tie::OnLine { l, .. } | Tie::Offset { l, .. } | Tie::Normal { l, .. } | Tie::Mirror { l, .. } => Some(l), + _ => None, + } + } + + /// The points it relates, given the lines' ends. + fn points(&self, ends: &dyn Fn(usize) -> Option<(usize, usize)>) -> Vec { + let line = |l: usize| ends(l).map(|(a, b)| [a, b]); + let mut v: Vec = match *self { + Tie::OnLine { p, l } => [p].into_iter().chain(line(l).into_iter().flatten()).collect(), + Tie::Offset { p, l, len } => { + let between = match len { + Len::Value(_) => None, + Len::Between(a, b) => Some([a, b]), + }; + [p].into_iter().chain(line(l).into_iter().flatten()).chain(between.into_iter().flatten()).collect() + } + Tie::Normal { p, e: q, l } | Tie::Mirror { p, q, l } => [p, q].into_iter().chain(line(l).into_iter().flatten()).collect(), + Tie::Midpoint { p, a, b } | Tie::Equidistant { c: p, a, b } => vec![p, a, b], + Tie::SameLength { p1, q1, p2, q2 } => vec![p1, q1, p2, q2], + }; + v.sort_unstable(); + v.dedup(); + v + } +} + +/// A circle or arc: its centre, and an arc's ends. +#[derive(Clone, Copy, Debug)] +struct Round { + c: usize, + ends: Option<(usize, usize)>, +} + +/// Everything the seed reads from a sketch, with the reference layout that picks branches +/// and signs. +pub(crate) struct Reading { + /// Relations between two points. + pub rels: Vec<(usize, usize, Rel)>, + /// Per point: its relations, sorted by the other point (independent of constraint order). + adj: Vec>, + ties: Vec, + /// Per tie: the points it relates (line ends included). + tie_points: Vec>, + /// Per point: the ties it takes part in. + ties_of: Vec>, + /// Per curve: a line's ends. + lines: Vec>, + /// Per curve: a line's direction sources. + sources: Vec>, + /// Per curve: the lines whose direction derives from it. + dependents: Vec>, + /// Per curve: the ties that read its direction. + using: Vec>, + /// Per point: the lines it ends. + lines_of: Vec>, + /// Per point: its position in the reference layout. + before: Vec>, +} + +/// The value each group of `m` items agrees on: `direct` values per item, groups joined by +/// `equal`. A group whose values disagree has none. +fn agreed(m: usize, direct: &[(usize, f64)], equal: &[(usize, usize)]) -> Vec> { + let mut parent: Vec = (0..m).collect(); + for &(a, b) in equal { + if a < m && b < m { + union(&mut parent, a, b); + } + } + let mut span: Vec> = vec![None; m]; + for &(i, v) in direct { + let root = find(&mut parent, i); + if let Some(s) = span.get_mut(root) { + Span::add(s, v); + } + } + (0..m).map(|i| span.get(find(&mut parent, i)).copied().flatten().and_then(Span::agreed)).collect() +} + +/// A line's level: horizontal (`Some(true)`) or vertical (`Some(false)`) when `angle` is a +/// multiple of a quarter turn. +fn level_at(angle: f64) -> Option { + if angle.sin().abs() < 1e-12 { + Some(true) + } else if angle.cos().abs() < 1e-12 { + Some(false) + } else { + None + } +} + +/// Per line: horizontal (`Some(true)`) or vertical (`Some(false)`), from horizontal, vertical +/// and axis-aligned line angle constraints carried through parallel, collinear (same), +/// perpendicular (swapped) lines and lines at a multiple of a quarter turn. In a group of +/// lines whose readings contradict each other, each line has only its own readings (when they +/// agree). A line with a line angle has none: a row or column has no direction, and the angle +/// gives the line its own (see `Src::Fixed`). +fn levels(lines: &[Option<(usize, usize)>], cons: &[&ConstraintKind]) -> Vec> { + use ConstraintKind::*; + let m = lines.len(); + let is_line = |l: usize| lines.get(l).is_some_and(Option::is_some); + let mut adj: Vec> = vec![Vec::new(); m]; + let mut seeds: Vec> = vec![Vec::new(); m]; + let mut angled = vec![false; m]; + for k in cons { + if let LineAngle { l, .. } = **k + && let Some(a) = angled.get_mut(l) + { + *a = true; + } + let (pair, seed) = match **k { + Parallel { a, b } | Collinear { a, b } => (Some((a, b, false)), None), + Perpendicular { a, b } => (Some((a, b, true)), None), + Angle { a, b, value, .. } if value.is_finite() => (level_at(value).map(|same| (a, b, !same)), None), + Horizontal { l } => (None, Some((l, true))), + Vertical { l } => (None, Some((l, false))), + LineAngle { l, value } if value.is_finite() => (None, level_at(value).map(|row| (l, row))), + _ => (None, None), + }; + if let Some((a, b, swap)) = pair.filter(|(a, b, _)| a != b && is_line(*a) && is_line(*b)) { + for (x, y) in [(a, b), (b, a)] { + if let Some(list) = adj.get_mut(x) { + list.push((y, swap)); + } + } + } + if let Some((l, row)) = seed.filter(|(l, _)| is_line(*l)) + && let Some(s) = seeds.get_mut(l) + { + s.push(row); + } + } + // Per component (breadth first from its lowest line): each line's parity relative to the + // root, and the root's level every seed agrees on. + let mut parity: Vec> = vec![None; m]; + let mut out = vec![None; m]; + for root in 0..m { + if parity.get(root).is_some_and(Option::is_some) || !is_line(root) { + continue; + } + let mut comp = vec![root]; + if let Some(p) = parity.get_mut(root) { + *p = Some(false); + } + let (mut consistent, mut level): (bool, Option>) = (true, None); + let mut k = 0; + while let Some(&x) = comp.get(k) { + k += 1; + let px = parity.get(x).copied().flatten().unwrap_or(false); + for &s in seeds.get(x).into_iter().flatten() { + let at_root = s != px; + level = match level { + None => Some(Some(at_root)), + Some(Some(v)) if v == at_root => Some(Some(v)), + Some(_) => Some(None), + }; + } + for &(y, swap) in adj.get(x).into_iter().flatten() { + match parity.get(y).copied().flatten() { + Some(py) => consistent &= py == (px != swap), + None => { + if let Some(p) = parity.get_mut(y) { + *p = Some(px != swap); + } + comp.push(y); + } + } + } + } + for x in comp { + let px = parity.get(x).copied().flatten().unwrap_or(false); + if let Some(o) = out.get_mut(x) + && !angled.get(x).copied().unwrap_or(false) + { + *o = match level { + Some(Some(at_root)) if consistent => Some(at_root != px), + // A contradiction: the line's own readings, when they agree. + _ => seeds.get(x).and_then(|s| s.first().copied().filter(|f| s.iter().all(|v| v == f))), + }; + } + } + } + out +} + +/// A circle or arc of `sk` (with valid point indices). +fn round_of(sk: &Sketch, k: usize) -> Option { + let n = sk.points.len(); + match sk.curves.get(k)?.kind { + CurveKind::Circle { c, .. } if c < n => Some(Round { c, ends: None }), + CurveKind::Arc { c, a, b } if c < n && a < n && b < n && c != a && c != b => Some(Round { c, ends: Some((a, b)) }), + _ => None, + } +} + +/// Per curve: a circle's or arc's radius as the driving dimensions set it (also through equal +/// circles and arcs), or a fixed or projected circle's own. +pub(crate) fn radii(sk: &Sketch) -> Vec> { + use ConstraintKind::*; + let mut radii = Vec::new(); + let mut equal = Vec::new(); + for (k, c) in sk.curves.iter().enumerate() { + if let CurveKind::Circle { r, .. } = c.kind + && (c.fixed || c.link.is_some()) + && radius_in_range(r) + { + radii.push((k, r)); + } + } + let round = |k: usize| round_of(sk, k).is_some(); + for c in sk.constraints.iter().filter(|c| !c.driven) { + match c.kind { + Radius { c, value } if round(c) && radius_in_range(value) => radii.push((c, value)), + Diameter { c, value } if round(c) && radius_in_range(value / 2.0) => radii.push((c, value / 2.0)), + Equal { a, b } if round(a) && round(b) => equal.push((a, b)), + _ => {} + } + } + agreed(sk.curves.len(), &radii, &equal) +} + +/// The unit direction of the line `ends` at the positions `pos`. +fn direction(ends: Option<(usize, usize)>, pos: &dyn Fn(usize) -> Option) -> Option { + let (a, b) = ends?; + (pos(b)? - pos(a)?).normalized() +} + +/// Read the driving constraints of `sk`; `before(i)` is point `i`'s position in the reference +/// layout (`None`, or out of range: unknown, and whatever it would decide is not read); +/// `on_curve` the sketch's point-on-curve pairs ([`crate::solver::points_on_curves`]), `radius` +/// its radii ([`radii`]). +/// +/// Every constraint on a line is read as a tie; a tie on a horizontal or vertical line is +/// then lowered to the relations between two points it amounts to (a row, a column, an offset +/// across it), which the decomposition builds clusters on. +pub(crate) fn read(sk: &Sketch, before: &dyn Fn(usize) -> Option, on_curve: &[(usize, usize)], radius: &[Option]) -> Reading { + use ConstraintKind::*; + let n = sk.points.len(); + let m = sk.curves.len(); + let before: Vec> = (0..n).map(|i| before(i).filter(|p| in_range(*p))).collect(); + let rf = |i: usize| before.get(i).copied().flatten(); + let lines: Vec> = sk + .curves + .iter() + .map(|c| match c.kind { + CurveKind::Line { a, b } if a < n && b < n && a != b => Some((a, b)), + _ => None, + }) + .collect(); + let rounds: Vec> = (0..m).map(|k| round_of(sk, k)).collect(); + let line = |l: usize| lines.get(l).copied().flatten(); + let round = |k: usize| rounds.get(k).copied().flatten(); + let cons: Vec<&ConstraintKind> = sk.constraints.iter().filter(|c| !c.driven).map(|c| &c.kind).collect(); + let dim = |v: f64| (v.is_finite() && (0.0..=MAX_COORD).contains(&v)).then_some(v); + + // Radii and lengths the dimensions set, also through equal curves. + let mut lengths = Vec::new(); + let mut equal_lines = Vec::new(); + for k in &cons { + match **k { + Length { l, value } if line(l).is_some() => lengths.extend(dim(value).map(|v| (l, v))), + Equal { a, b } if line(a).is_some() && line(b).is_some() => equal_lines.push((a, b)), + _ => {} + } + } + let length = agreed(m, &lengths, &equal_lines); + let level = levels(&lines, &cons); + let r_of = |k: usize| radius.get(k).copied().flatten(); + let level_of = |l: usize| level.get(l).copied().flatten(); + let ref_dir = |l: usize| direction(line(l), &rf); + + let mut rels = Vec::new(); + let mut ties = Vec::new(); + let mut rel = |p: usize, q: usize, r: Rel| { + if p != q && p < n && q < n { + rels.push((p, q, r)); + } + }; + for k in &cons { + match **k { + Coincident { p, q } => rel(p, q, Rel::Same), + HorizontalPoints { p, q } => rel(p, q, Rel::SameY), + VerticalPoints { p, q } => rel(p, q, Rel::SameX), + Distance { p, q, value } | DistanceX { p, q, value } | DistanceY { p, q, value } => { + if let Some(d) = dim(value) { + let r = match **k { + DistanceX { .. } => Rel::OffsetX(d), + DistanceY { .. } => Rel::OffsetY(d), + _ => Rel::Dist(d), + }; + rel(p, q, r); + } + } + PointOnCurve { p, c } if p < n => { + if line(c).is_some() { + ties.push(Tie::OnLine { p, l: c }); + } else if let Some(rd) = round(c) { + match (r_of(c), rd.ends) { + (Some(r), _) => rel(rd.c, p, Rel::Dist(r)), + (None, Some((a, b))) if p != a && p != b => ties.push(Tie::Equidistant { c: rd.c, a, b: p }), + _ => {} + } + } + } + PointLineDistance { p, l, value } | LinearDiameter { p, l, value } if p < n && line(l).is_some() => { + let d = if matches!(**k, LinearDiameter { .. }) { value / 2.0 } else { value }; + if let Some(d) = dim(d) { + ties.push(Tie::Offset { p, l, len: Len::Value(d) }); + } + } + Tangent { a, b } => { + let (l, curve) = if line(a).is_some() { (a, b) } else { (b, a) }; + if line(l).is_some() + && let Some(rd) = round(curve) + { + if let Some(len) = r_of(curve).map(Len::Value).or(rd.ends.map(|(s, _)| Len::Between(rd.c, s))) { + ties.push(Tie::Offset { p: rd.c, l, len }); + } + // The radius to the touch point is normal to the line. + if let Some(e) = crate::solver::touch_point(sk, k, on_curve).filter(|e| *e < n && *e != rd.c) { + ties.push(Tie::Normal { p: rd.c, e, l }); + } + } else if let (Some(r1), Some(r2), Some(k1), Some(k2)) = (r_of(a), r_of(b), round(a), round(b)) { + // External or internal, as the residual picks it on the reference. + let ref_r = |k: usize, rd: Round| match (sk.curves.get(k).map(|c| &c.kind), rd.ends) { + (Some(CurveKind::Circle { r, .. }), _) => Some(*r), + (_, Some((s, _))) => Some(rf(rd.c)?.dist(rf(s)?)), + _ => None, + }; + if let (Some(c1), Some(c2), Some(q1), Some(q2)) = (rf(k1.c), rf(k2.c), ref_r(a, k1), ref_r(b, k2)) { + let d = c1.dist(c2); + let external = (d - (q1 + q2)).abs() <= (d - (q1 - q2).abs()).abs(); + rel(k1.c, k2.c, Rel::Dist(if external { r1 + r2 } else { (r1 - r2).abs() })); + } + } + } + Concentric { a, b } => { + if let (Some(k1), Some(k2)) = (round(a), round(b)) { + rel(k1.c, k2.c, Rel::Same); + } + } + SlotOverallLength { a, b, value } => { + if let (Some(k1), Some(k2), Some(r1), Some(r2)) = (round(a), round(b), r_of(a), r_of(b)) + && let Some(d) = dim(value - r1 - r2) + { + rel(k1.c, k2.c, Rel::Dist(d)); + } + } + Midpoint { p, l } if p < n => { + if let Some((a, b)) = line(l).filter(|(a, b)| p != *a && p != *b) { + ties.push(Tie::OnLine { p, l }); + ties.push(Tie::Midpoint { p, a, b }); + } + } + Symmetric { p, q, l } if p < n && q < n && p != q && line(l).is_some() => { + ties.push(Tie::Normal { p, e: q, l }); + ties.push(Tie::Mirror { p, q, l }); + } + Collinear { a, b } => { + if let (Some((a0, a1)), Some((b0, b1))) = (line(a), line(b)) { + for (p, l) in [(b0, a), (b1, a), (a0, b), (a1, b)] { + ties.push(Tie::OnLine { p, l }); + } + } + } + Equal { a, b } => { + if let (Some((a0, a1)), Some((b0, b1))) = (line(a), line(b)) { + if length.get(a).copied().flatten().is_none() { + ties.push(Tie::SameLength { p1: a0, q1: a1, p2: b0, q2: b1 }); + } + } else if let (Some(Round { c: c1, ends: Some((s1, _)) }), Some(Round { c: c2, ends: Some((s2, _)) })) = (round(a), round(b)) + && r_of(a).is_none() + { + ties.push(Tie::SameLength { p1: c1, q1: s1, p2: c2, q2: s2 }); + } + } + _ => {} + } + } + for (l, ends) in lines.iter().enumerate() { + if let (Some((a, b)), Some(d)) = (*ends, length.get(l).copied().flatten()) { + rel(a, b, Rel::Dist(d)); + } + } + for (k, rd) in rounds.iter().enumerate() { + let Some(Round { c, ends: Some((a, b)) }) = *rd else { continue }; + match r_of(k) { + Some(r) => { + rel(c, a, Rel::Dist(r)); + rel(c, b, Rel::Dist(r)); + } + None if a != b => ties.push(Tie::Equidistant { c, a, b }), + None => {} + } + } + + // Line directions: fixed ones, and turns from other lines. + let mut sources: Vec> = vec![Vec::new(); m]; + { + // The turn from `from` to `l` among `base` and `base + π` the reference has (`base` + // when it has none). + let turn = |from: usize, l: usize, base: f64| match (ref_dir(from), ref_dir(l)) { + (Some(f), Some(d)) if turned(f, base).dot(d) < 0.0 => base + std::f64::consts::PI, + _ => base, + }; + let mut add = |l: usize, s: Src| { + if let Some(list) = sources.get_mut(l) { + list.push(s); + } + }; + for k in &cons { + match **k { + LineAngle { l, value } if line(l).is_some() && value.is_finite() => add(l, Src::Fixed(Vec2::from_angle(value))), + Angle { a, b, value, flip } if a != b && line(a).is_some() && line(b).is_some() && value.is_finite() && value.abs() <= MAX_COORD => { + let by = value + if flip { std::f64::consts::PI } else { 0.0 }; + add(b, Src::Turn { from: a, by }); + add(a, Src::Turn { from: b, by: -by }); + } + Parallel { a, b } | Collinear { a, b } | Perpendicular { a, b } if a != b && line(a).is_some() && line(b).is_some() => { + let base = if matches!(**k, Perpendicular { .. }) { std::f64::consts::FRAC_PI_2 } else { 0.0 }; + add(b, Src::Turn { from: a, by: turn(a, b, base) }); + add(a, Src::Turn { from: b, by: turn(b, a, base) }); + } + _ => {} + } + } + // A row or column gives no sign: the reference's (a line with a line angle has its + // own, and no level). + for l in 0..m { + if let Some(row) = level_of(l) { + let axis = if row { Vec2::X } else { Vec2::Y }; + let d = if ref_dir(l).is_some_and(|d| d.dot(axis) < 0.0) { -axis } else { axis }; + add(l, Src::Fixed(d)); + } + } + } + let mut dependents: Vec> = vec![Vec::new(); m]; + for (l, list) in sources.iter_mut().enumerate() { + list.sort_by_key(Src::key); + list.dedup(); + list.truncate(MAX_SOURCES); + // A line with a direction runs along it from either end. + if let (Some((_, b)), false) = (line(l), list.is_empty()) { + ties.push(Tie::OnLine { p: b, l }); + } + for s in list.iter() { + if let Src::Turn { from, .. } = s + && let Some(d) = dependents.get_mut(*from) + { + d.push(l); + } + } + } + for d in &mut dependents { + d.sort_unstable(); + d.dedup(); + } + + // Ties on a horizontal or vertical line are relations between two points: a row or + // column through the line's ends, an offset across it, a column or row normal to it. + let lowered = |row: bool, d: Option| match (row, d) { + (true, None) => Rel::SameY, + (false, None) => Rel::SameX, + (true, Some(d)) => Rel::OffsetY(d), + (false, Some(d)) => Rel::OffsetX(d), + }; + ties.retain(|t| { + let Some(((a, b), row)) = t.line().and_then(|l| Some((line(l)?, level_of(l)?))) else { return true }; + match *t { + Tie::OnLine { p, .. } => [a, b].into_iter().for_each(|q| rel(q, p, lowered(row, None))), + Tie::Offset { p, len: Len::Value(d), .. } => [a, b].into_iter().for_each(|q| rel(q, p, lowered(row, Some(d)))), + Tie::Normal { p, e, .. } => rel(e, p, lowered(!row, None)), + Tie::Offset { .. } | Tie::Mirror { .. } | Tie::Midpoint { .. } | Tie::SameLength { .. } | Tie::Equidistant { .. } => return true, + } + false + }); + + ties.sort_by_key(Tie::key); + ties.dedup(); + let tie_points: Vec> = ties.iter().map(|t| t.points(&line).into_iter().filter(|p| *p < n).collect()).collect(); + let mut ties_of = vec![Vec::new(); n]; + let mut using = vec![Vec::new(); m]; + for (i, (t, pts)) in ties.iter().zip(&tie_points).enumerate() { + for p in pts { + if let Some(list) = ties_of.get_mut(*p) { + list.push(i); + } + } + if let Some(list) = t.line().and_then(|l| using.get_mut(l)) { + list.push(i); + } + } + let mut lines_of = vec![Vec::new(); n]; + for (l, ends) in lines.iter().enumerate() { + for p in ends.iter().flat_map(|(a, b)| [*a, *b]) { + if let Some(list) = lines_of.get_mut(p) { + list.push(l); + } + } + } + let mut adj = vec![Vec::new(); n]; + for &(p, q, rel) in &rels { + for end in [p, q] { + if let Some(list) = adj.get_mut(end) { + list.push(Edge { p, q, rel }); + } + } + } + for (i, list) in adj.iter_mut().enumerate() { + list.sort_by_key(|e: &Edge| (e.other(i), e.rel.key())); + } + Reading { rels, adj, ties, tie_points, ties_of, lines, sources, dependents, using, lines_of, before } +} + +/// The points as one frame has them: `at(i)` the position of point `i` (None while +/// unplaced), `oriented` when the frame is turned as the sketch is (directions fixed in the +/// sketch hold). +pub(crate) struct View<'a> { + pub at: &'a dyn Fn(usize) -> Option, + pub oriented: bool, +} + +impl Reading { + /// How many points the sketch has. + pub(crate) fn points(&self) -> usize { + self.adj.len() + } + + /// How many ties it read. + pub(crate) fn tie_count(&self) -> usize { + self.ties.len() + } + + /// The relations of point `i`. + pub(crate) fn adj(&self, i: usize) -> &[Edge] { + self.adj.get(i).map_or(&[][..], Vec::as_slice) + } + + /// How many ties point `i` takes part in. + pub(crate) fn tie_count_of(&self, i: usize) -> usize { + self.ties_of.get(i).map_or(0, Vec::len) + } + + pub(crate) fn before(&self, i: usize) -> Option { + self.before.get(i).copied().flatten() + } + + /// The point lists of every tie. + pub(crate) fn tie_point_lists(&self) -> impl Iterator { + self.tie_points.iter().map(Vec::as_slice) + } + + /// The points related to `i` by a relation. + pub(crate) fn related(&self, i: usize) -> impl Iterator + '_ { + self.adj(i).iter().map(move |e| e.other(i)) + } + + /// The points related to `i` (by a relation or a tie). + pub(crate) fn neighbours(&self, i: usize) -> impl Iterator + '_ { + let ties = self.ties_of.get(i).into_iter().flatten().flat_map(|t| self.tie_points.get(*t).into_iter().flatten().copied()); + self.related(i).chain(ties).filter(move |o| *o != i) + } + + /// The points a tie may now place once `p` is placed, each once: the points of its ties, + /// and of the ties reading the direction of a line it ends or of a line whose direction + /// derives from one (at most [`MAX_WOKEN_LINES`] lines, [`DEPTH`] deep). + pub(crate) fn woken(&self, p: usize) -> Vec { + let mut lines: Vec = self.lines_of.get(p).cloned().unwrap_or_default(); + let mut start = 0; + 'deeper: for _ in 0..DEPTH { + let end = lines.len(); + for k in start..end { + let Some(l) = lines.get(k).copied() else { break }; + for d in self.dependents.get(l).into_iter().flatten() { + if lines.len() >= MAX_WOKEN_LINES { + break 'deeper; + } + if !lines.contains(d) { + lines.push(*d); + } + } + } + start = end; + } + let ties = self.ties_of.get(p).into_iter().flatten().chain(lines.iter().flat_map(|l| self.using.get(*l).into_iter().flatten())); + let mut out: Vec = ties.flat_map(|t| self.tie_points.get(*t).into_iter().flatten().copied()).filter(|o| *o != p).collect(); + out.sort_unstable(); + out.dedup(); + out + } + + /// The relations of `t` to points placed in `view`: up to [`MAX_LOCI`] items, relations + /// between two points first. + pub(crate) fn items(&self, t: usize, view: &View) -> Vec { + let mut out = Vec::new(); + let t_before = self.before(t); + for e in self.adj(t) { + if out.len() >= MAX_LOCI { + return out; + } + if !view.oriented && e.rel.along_axis() { + continue; + } + let anchor = e.other(t); + let Some(at) = (view.at)(anchor) else { continue }; + if let Rel::Same = e.rel { + out.push(Item { anchor, shape: Shape::At(at) }); + continue; + } + let side = e.rel.offset_side(t_before, self.before(anchor)); + let Some(locus) = e.rel.locus(at, side) else { continue }; + let then = self.before(anchor).and_then(|b| e.rel.locus(b, side)); + out.push(Item { anchor, shape: Shape::On { locus, then } }); + } + for ti in self.ties_of.get(t).into_iter().flatten() { + if out.len() >= MAX_LOCI { + break; + } + if let Some(item) = self.ties.get(*ti).and_then(|tie| self.tie_item(tie, t, view)) { + out.push(item); + } + } + out + } + + /// Does `t` at `x` hold every relation and tie to the points placed in `view`? + pub(crate) fn holds(&self, t: usize, x: Vec2, view: &View) -> bool { + let pos = |i: usize| if i == t { Some(x) } else { (view.at)(i) }; + self.adj(t).iter().all(|e| (view.at)(e.other(t)).is_none_or(|q| e.rel.holds(x, q, 0.0))) + && self.ties_of.get(t).into_iter().flatten().filter_map(|ti| self.ties.get(*ti)).all(|tie| self.tie_holds(tie, &pos)) + } + + fn ends(&self, l: usize) -> Option<(usize, usize)> { + self.lines.get(l).copied().flatten() + } + + /// Line `l`'s unit direction in the reference layout. + fn ref_dir(&self, l: usize) -> Option { + direction(self.ends(l), &|i| self.before(i)) + } + + /// Line `l`'s unit direction in `view`: from its placed ends, or derived from its sources + /// (at most `depth` lines deep). + fn dir(&self, l: usize, view: &View, depth: usize) -> Option { + if let Some(d) = direction(self.ends(l), view.at) { + return Some(d); + } + if depth == 0 { + return None; + } + self.sources.get(l)?.iter().find_map(|s| match *s { + Src::Fixed(d) => view.oriented.then_some(d), + Src::Turn { from, by } => self.dir(from, view, depth - 1).map(|d| turned(d, by)), + }) + } + + /// A placed point of line `l` other than `t` (its first end first). + fn line_point(&self, l: usize, t: usize, view: &View) -> Option<(usize, Vec2)> { + let (a, b) = self.ends(l)?; + [a, b].into_iter().filter(|p| *p != t).find_map(|p| (view.at)(p).map(|at| (p, at))) + } + + /// The item tie `tie` gives point `t` from the points placed in `view`. + fn tie_item(&self, tie: &Tie, t: usize, view: &View) -> Option { + let at = |i: usize| (view.at)(i); + let rf = |i: usize| self.before(i); + let line = |o: Vec2, d: Vec2| Locus::Line { o, d }.checked(); + let circle = |c: Vec2, r: f64| Locus::Circle { c, r }.checked(); + let on = |anchor: usize, locus: Option, then: Option| locus.map(|locus| Item { anchor, shape: Shape::On { locus, then } }); + let point = |anchor: usize, x: Vec2| in_range(x).then_some(Item { anchor, shape: Shape::At(x) }); + match *tie { + Tie::OnLine { p, l } => { + let (a, b) = self.ends(l)?; + // `p` along the line from a placed end, or an end along the line from `p`. + let (anchor, o) = if t == p { + self.line_point(l, t, view)? + } else if t == a || t == b { + (p, at(p)?) + } else { + return None; + }; + let d = self.dir(l, view, DEPTH)?; + on(anchor, line(o, d), rf(anchor).zip(self.ref_dir(l)).and_then(|(o, d)| line(o, d))) + } + Tie::Offset { p, l, len } => { + let (a, b) = self.ends(l)?; + // `p` is at `s·v` along the line's left normal from it. + let (anchor, o, sign) = if t == p { + let (q, o) = self.line_point(l, t, view)?; + (q, o, 1.0) + } else if t == a || t == b { + (p, at(p)?, -1.0) + } else { + return None; + }; + let v = len.value(&at)?; + let rd = self.ref_dir(l)?; + let (pb, ab) = (rf(p)?, rf(a)?); + let s = if v == 0.0 { 1.0 } else { side(rd.cross(pb - ab), v, mag(pb).max(mag(ab)))? }; + let d = self.dir(l, view, DEPTH)?; + let shift = sign * s * v; + on(anchor, line(o + d.perp() * shift, d), line(rf(anchor)? + rd.perp() * shift, rd)) + } + Tie::Normal { p, e, l } => { + let anchor = partner(t, p, e)?; + let o = at(anchor)?; + let d = self.dir(l, view, DEPTH)?.perp(); + let then = rf(anchor).zip(self.ref_dir(l)).and_then(|(o, d)| line(o, d.perp())); + on(anchor, line(o, d), then) + } + Tie::Midpoint { p, a, b } => { + if t == p { + point(a.min(b), (at(a)? + at(b)?) * 0.5) + } else { + let other = partner(t, a, b)?; + point(p.min(other), at(p)? * 2.0 - at(other)?) + } + } + Tie::Mirror { p, q, l } => { + let from = partner(t, p, q)?; + let x = at(from)?; + let (_, o) = self.line_point(l, t, view)?; + point(from, reflected(x, o, self.dir(l, view, DEPTH)?)) + } + Tie::SameLength { p1, q1, p2, q2 } => { + let (centre, (x, y)) = match (partner(t, p2, q2), partner(t, p1, q1)) { + (Some(c), None) => (c, (p1, q1)), + (None, Some(c)) => (c, (p2, q2)), + _ => return None, + }; + let r = at(x)?.dist(at(y)?); + on(centre, circle(at(centre)?, r), rf(centre).and_then(|c| circle(c, r))) + } + Tie::Equidistant { c, a, b } => { + if t == c { + let (pa, pb) = (at(a)?, at(b)?); + let n = (pb - pa).normalized()?.perp(); + let then = rf(a).zip(rf(b)).and_then(|(ra, rb)| line((ra + rb) * 0.5, (rb - ra).normalized()?.perp())); + on(a.min(b), line((pa + pb) * 0.5, n), then) + } else { + let other = partner(t, a, b)?; + let r = at(other)?.dist(at(c)?); + on(c, circle(at(c)?, r), rf(c).and_then(|x| circle(x, r))) + } + } + } + } + + /// Does `tie` hold at the positions `pos` (true when a point it needs is unknown)? + fn tie_holds(&self, tie: &Tie, pos: &dyn Fn(usize) -> Option) -> bool { + let check = |miss: f64, size: f64, pts: &[Vec2]| within(miss, size, pts.iter().fold(0.0, |m, p| m.max(mag(*p)))); + let base = |l: usize| self.ends(l).and_then(|(a, b)| Some((pos(a)?, direction(Some((a, b)), pos)?))); + match *tie { + Tie::OnLine { p, l } => match (pos(p), base(l)) { + (Some(x), Some((a, d))) => check(d.cross(x - a).abs(), 0.0, &[x, a]), + _ => true, + }, + Tie::Offset { p, l, len } => match (pos(p), base(l), len.value(pos)) { + (Some(x), Some((a, d)), Some(v)) => check((d.cross(x - a).abs() - v).abs(), v, &[x, a]), + _ => true, + }, + Tie::Normal { p, e, l } => match (pos(p), pos(e), base(l)) { + (Some(x), Some(y), Some((_, d))) => check((x - y).dot(d).abs(), x.dist(y), &[x, y]), + _ => true, + }, + Tie::Midpoint { p, a, b } => match (pos(p), pos(a), pos(b)) { + (Some(x), Some(a), Some(b)) => check(x.dist((a + b) * 0.5), a.dist(b), &[x, a, b]), + _ => true, + }, + Tie::Mirror { p, q, l } => match (pos(p), pos(q), base(l)) { + (Some(x), Some(y), Some((o, d))) => check(y.dist(reflected(x, o, d)), x.dist(o), &[x, y, o]), + _ => true, + }, + Tie::SameLength { p1, q1, p2, q2 } => match (pos(p1), pos(q1), pos(p2), pos(q2)) { + (Some(a), Some(b), Some(c), Some(d)) => check((a.dist(b) - c.dist(d)).abs(), a.dist(b), &[a, b, c, d]), + _ => true, + }, + Tie::Equidistant { c, a, b } => match (pos(c), pos(a), pos(b)) { + (Some(c), Some(a), Some(b)) => check((a.dist(c) - b.dist(c)).abs(), a.dist(c), &[c, a, b]), + _ => true, + }, + } + } +} diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 0873bfc1..7d84ea8f 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; -use solvecraft_geom::{Seg2, Vec2}; +use solvecraft_geom::{Seg2, Vec2, Vec3}; + +use crate::union_find::{find, union}; /// Hard caps that keep hostile input from exhausting memory or time. pub const MAX_POINTS: usize = 20_000; @@ -7,6 +9,24 @@ pub const MAX_CONSTRAINTS: usize = 40_000; /// Curves need points, so this follows the point cap. pub const MAX_CURVES: usize = MAX_POINTS; +/// Largest coordinate or dimension value (mm) a sketch accepts. +pub(crate) const MAX_COORD: f64 = 1e9; + +/// A position a sketch accepts: finite and within [`MAX_COORD`]. +pub(crate) fn in_range(p: Vec2) -> bool { + p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD +} + +/// [`in_range`] for a 3D position. +pub(crate) fn in_range3(p: Vec3) -> bool { + p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD && p.z.abs() < MAX_COORD +} + +/// A radius a sketch accepts: finite, positive and within [`MAX_COORD`]. +pub(crate) fn radius_in_range(r: f64) -> bool { + r.is_finite() && r > 1e-9 && r < MAX_COORD +} + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum SketchError { #[error("unknown sketch entity `{0}`")] @@ -367,7 +387,8 @@ impl ConstraintKind { pub fn refers_to_point(&self, p: usize) -> bool { self.indices().0.contains(&p) } - fn indices(&self) -> (Vec, Vec) { + /// The points and the curves the constraint refers to. + pub fn indices(&self) -> (Vec, Vec) { use ConstraintKind::*; match *self { Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]), @@ -607,7 +628,7 @@ impl Sketch { } fn check_pos(p: Vec2) -> Result { - if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) } + if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) } } /// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`). @@ -679,7 +700,7 @@ impl Sketch { } pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option, id: Option<&str>) -> Result { - if !(r.is_finite() && r > 1e-9 && r < 1e9) { + if !radius_in_range(r) { return Err(SketchError::Invalid(format!("radius {r}"))); } let id = self.curve_id(id, "c")?; @@ -703,7 +724,7 @@ impl Sketch { /// the circle through `p0`). pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option; 3], id: Option<&str>) -> Result { let r = center.dist(p0); - if !(r.is_finite() && r > 1e-9 && r < 1e9) { + if !radius_in_range(r) { return Err(SketchError::Invalid("degenerate arc".into())); } let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r; @@ -838,7 +859,7 @@ impl Sketch { _ => {} } if let Some(v) = k.value() - && (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle())) + && (!v.is_finite() || v.abs() > MAX_COORD || (v < 0.0 && !k.is_angle())) { return Err(SketchError::Invalid(format!("dimension value {v}"))); } @@ -858,7 +879,7 @@ impl Sketch { || self.curves.iter().any(|c| c.fixed && c.kind.uses(i)) } - /// [`Sketch::point_locked`] for every point at once. + /// [`Sketch::point_locked`] for every point at once: the points every solve keeps as they are. pub fn locked_points(&self) -> Vec { let mut out: Vec = self.points.iter().map(|p| p.fixed || p.link.is_some()).collect(); let mut set = |i: usize| { @@ -887,21 +908,9 @@ impl Sketch { } // Points made one by coincident constraints. let mut root: Vec = (0..self.points.len()).collect(); - fn find(r: &mut [usize], mut i: usize) -> usize { - while let Some(&p) = r.get(i) { - if p == i { - break; - } - i = p; - } - i - } for c in &self.constraints { if let ConstraintKind::Coincident { p, q } = c.kind { - let (a, b) = (find(&mut root, p), find(&mut root, q)); - if let Some(x) = root.get_mut(a) { - *x = b; - } + union(&mut root, p, q); } } let ends: Vec> = @@ -1007,6 +1016,77 @@ impl Sketch { out } + /// Every point a constraint touches: its points and the points of its curves. + pub fn constraint_points(&self, k: &ConstraintKind) -> Vec { + let (mut pts, curves) = k.indices(); + pts.extend(curves.into_iter().flat_map(|c| self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default())); + pts + } + + /// What a solve holding `points` and the radii of `radii` moves with them, as the solver + /// couples its variables: (per point, per curve radius). The held points, and the free + /// points and free circle/ellipse radii joined to them through curves and driving + /// constraints without passing through a constant (a held, fixed or projected point or + /// radius couples nothing). + pub fn coupled(&self, points: &[usize], radii: &[usize]) -> (Vec, Vec) { + let (n, m) = (self.points.len(), self.curves.len()); + // Nodes: points, then curve radii, then one node standing for everything held. + let held = n + m; + let mut root: Vec = (0..=held).collect(); + let mut seed = vec![false; n + m]; + for v in points.iter().copied().chain(radii.iter().map(|c| n.saturating_add(*c))) { + if let Some(s) = seed.get_mut(v) { + *s = true; + } + } + let locked = self.locked_points(); + let free = |v: usize| { + if seed.get(v).copied().unwrap_or(true) { + return false; + } + match v.checked_sub(n) { + None => !locked.get(v).copied().unwrap_or(true), + Some(c) => self + .curves + .get(c) + .is_some_and(|x| matches!(x.kind, CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) && x.link.is_none() && !x.fixed), + } + }; + let curve_nodes = |c: usize| { + let mut v = self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default(); + v.push(n + c); + v + }; + // What the solver's equations join (see `solve_with`): an arc's centre and ends, a + // periodic spline's first and last points, and each driving constraint's entities. A + // curve alone joins nothing (a circle's radius and centre move independently). + let own = self.curves.iter().filter_map(|x| match &x.kind { + CurveKind::Arc { c, a, b } => Some(vec![*c, *a, *b]), + CurveKind::Spline { pts, periodic: true, .. } => match (pts.first(), pts.last()) { + (Some(a), Some(b)) if a != b => Some(vec![*a, *b]), + _ => None, + }, + _ => None, + }); + let edges = own.chain(self.constraints.iter().filter(|c| !c.driven).map(|c| { + let (mut v, curves) = c.kind.indices(); + v.extend(curves.into_iter().flat_map(curve_nodes)); + v + })); + for e in edges { + let mut join = e.iter().copied().filter(|&v| free(v)); + let first = if e.iter().any(|v| seed.get(*v).copied().unwrap_or(false)) { Some(held) } else { join.next() }; + if let Some(first) = first { + for v in join { + union(&mut root, first, v); + } + } + } + let r = find(&mut root, held); + let mut coupled = |v: usize| seed.get(v).copied().unwrap_or(false) || (free(v) && find(&mut root, v) == r); + ((0..n).map(&mut coupled).collect(), (n..n + m).map(&mut coupled).collect()) + } + /// Is curve `c` held in place: fixed or projected (linked). pub fn curve_locked(&self, c: usize) -> bool { self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id)) @@ -1139,7 +1219,7 @@ impl Sketch { CurveKind::Circle { .. } => "c", CurveKind::Arc { .. } => "a", CurveKind::Ellipse { m, c, r } => { - if m == c || !(r.is_finite() && *r > 1e-9 && *r < 1e9) { + if m == c || !radius_in_range(*r) { return Err(SketchError::Invalid("degenerate ellipse".into())); } "e" diff --git a/crates/sketch/src/periodic.rs b/crates/sketch/src/periodic.rs index b9e408e6..e0c5a426 100644 --- a/crates/sketch/src/periodic.rs +++ b/crates/sketch/src/periodic.rs @@ -1,4 +1,5 @@ //! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation. +use crate::model::in_range; use crate::{MAX_SPLINE_POINTS, Sketch, SketchError}; use solvecraft_geom::Vec2; @@ -11,7 +12,7 @@ pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> { if points.len() < 4 || points.len() > MAX_SPLINE_POINTS || points.first() != points.last() - || points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) + || points.iter().any(|p| !in_range(*p)) || points.windows(2).any(|p| { let h = p[0].dist(p[1]); !h.is_finite() || h <= 1e-9 @@ -89,7 +90,7 @@ pub fn periodic_beziers(points: &[Vec2]) -> Result> { let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0); let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0); let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]]; - if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) { + if cubic.iter().any(|p| !in_range(*p)) { return Err(bad()); } out.push(cubic); @@ -162,7 +163,7 @@ mod tests { sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap(); sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap(); sk.points[first].pos = Vec2::new(0.2, 0.1); - assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok()); + assert!(crate::solve_with(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }, None, crate::Analysis::Full, None).ok()); assert_eq!(sk.point(first), sk.point(seam)); assert_eq!(sk.point(first), sk.point(last)); sk.validate_periodic_splines().unwrap(); @@ -176,7 +177,7 @@ mod tests { let mut collapsed = sk; collapsed.points[a].pos = collapsed.points[first].pos; let before = collapsed.clone(); - assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok()); + assert!(!crate::solve_with(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }, None, crate::Analysis::Full, None).ok()); assert_eq!(collapsed, before); } diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 98e90e33..a8da95ed 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -14,6 +14,7 @@ use solvecraft_geom::{Loop2, Region2, Seg2, Vec2}; use crate::model::{CurveKind, Sketch}; use crate::shape::{Shape, intersections}; +use crate::union_find::{find, union}; const MERGE_TOL: f64 = 1e-6; const MAX_EDGES: usize = 50_000; @@ -399,16 +400,6 @@ impl Grid { } } -fn find(p: &mut [usize], mut i: usize) -> usize { - while let Some(&q) = p.get(i) { - if q == i { - break; - } - i = q; - } - i -} - /// Signed curvature at the start of a segment (left turns positive). fn curvature(s: &Seg2) -> f64 { match *s { @@ -756,12 +747,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut comp: Vec = (0..n).collect(); for (e, a) in edges.iter().zip(&alive) { if *a { - let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v)); - if ru != rv - && let Some(s) = comp.get_mut(rv) - { - *s = ru; - } + union(&mut comp, e.v, e.u); } } diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index c7caf90b..2b0ed3a1 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -7,12 +7,19 @@ //! central differences. Small steps from the current positions are preferred, so //! under-constrained sketches move as little as possible. Afterwards the rank of the Jacobian //! gives the remaining degrees of freedom and which points are fully determined. +//! +//! Unknowns the constraints pin down one after another from the constants (a point at the +//! crossing of two loci on known points, see [`pinned`]) are eliminated exactly, without +//! the dense matrices: values the constructive seed placed stay constants for LM, and the rank +//! analysis runs only on what remains. use serde::{Deserialize, Serialize}; use solvecraft_geom::{Seg2, Vec2}; +use crate::decompose::Budget; use crate::linalg::{Mat, determined_vars, solve as lin_solve}; use crate::model::{ConstraintKind, CurveKind, Sketch}; +use crate::union_find::{find, union}; const MAX_ITERS: usize = 500; /// Variables in one connected component. Each component is solved as a dense system: an n by n @@ -43,8 +50,17 @@ pub struct SolveReport { pub dof: usize, pub iterations: usize, pub max_residual: f64, - /// Constraints still violated after a failed solve. + /// Constraints still violated after a failed solve, and the ones in `folded`. pub failing: Vec, + /// Constraints whose orientation (a triangle's turn, an offset's side) the solve for edited + /// driving values flipped relative to the stored solved state, where continuation could + /// not keep it. The solve keeps that best result (an evaluation shows it with a warning); + /// a command that changes values refuses it. + #[serde(default)] + pub folded: Vec, + /// Components this solve stepped from the stored solved state to the edited values. + #[serde(default)] + pub continuations: usize, /// Per point: fully determined by constraints (fixed points count as determined). pub point_determined: Vec, /// Per curve: all of its defining values determined. @@ -58,13 +74,44 @@ impl SolveReport { pub fn fully_constrained(&self) -> bool { self.dof == 0 } + /// Solved, and no orientation of the stored state flipped: what a value edit accepts. + pub fn keeps_orientation(&self) -> bool { + self.ok() && self.folded.is_empty() + } + /// Why a solve failed or turned over: the constraints whose orientation the edit flips, + /// or else `conflict` of the constraints still violated (listed, comma-separated). + pub fn failure(&self, conflict: impl FnOnce(&str) -> String) -> String { + if self.folded.is_empty() { + conflict(&self.failing.join(", ")) + } else { + format!("the new values would flip the sketch over ({} would change orientation)", self.folded.join(", ")) + } + } } -/// Variable layout: point → (x var, y var), circle → radius var. +/// Variable layout: point → (x var, y var), circle → radius var. The point coordinates come +/// first, in pairs (variables `0..points`), then the radii. struct Layout { pvar: Vec>, rvar: Vec>, n: usize, + /// The point coordinates' variables: `0..points`. + points: usize, +} + +impl Layout { + /// The variable is a point coordinate. + fn is_point(&self, v: usize) -> bool { + v < self.points + } + /// The first variable of the unknown `v` belongs to: its point's x, or the radius itself. + fn unit(&self, v: usize) -> usize { + if self.is_point(v) { v & !1 } else { v } + } + /// The variables of the unknown starting at `first`. + fn width(&self, first: usize) -> usize { + if self.is_point(first) { 2 } else { 1 } + } } /// Geometry held in place for one solve: point indices and curves whose radius is held. @@ -74,29 +121,21 @@ pub struct Hold { pub radii: Vec, } -fn layout(sk: &Sketch, hold: &Hold) -> Layout { - let mut fixed: Vec = sk.points.iter().map(|p| p.fixed).collect(); +/// Per point: a constant for this solve (locked — fixed, fixed by a constraint, a point of a +/// fixed curve, linked — or held). +pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec { + let mut fixed = sk.locked_points(); for p in &hold.points { if let Some(f) = fixed.get_mut(*p) { *f = true; } } - for c in &sk.constraints { - if let ConstraintKind::Fix { p } = c.kind - && let Some(f) = fixed.get_mut(p) - { - *f = true; - } - } - for c in sk.curves.iter().filter(|c| c.fixed) { - for p in c.kind.point_ids() { - if let Some(f) = fixed.get_mut(p) { - *f = true; - } - } - } + fixed +} + +fn layout(sk: &Sketch, hold: &Hold) -> Layout { let mut n = 0; - let pvar = fixed + let pvar = held_points(sk, hold) .iter() .map(|f| { if *f { @@ -107,6 +146,7 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout { } }) .collect(); + let points = n; let rvar = sk .curves .iter() @@ -119,7 +159,7 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout { _ => None, }) .collect(); - Layout { pvar, rvar, n } + Layout { pvar, rvar, n, points } } /// Read-only view of the sketch with variable values substituted. @@ -553,7 +593,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option { /// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a /// constraint that is not a dimension. pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option { - let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 }; + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite()) } @@ -613,9 +653,23 @@ fn block_residuals(s: &State, b: &Block, out: &mut Vec) { } } +/// Every (point, curve) pair of a point-on-curve constraint, sorted (for [`touch_point`]). +pub(crate) fn points_on_curves(sk: &Sketch) -> Vec<(usize, usize)> { + let mut on: Vec<(usize, usize)> = sk + .constraints + .iter() + .filter_map(|c| match c.kind { + ConstraintKind::PointOnCurve { p, c } => Some((p, c)), + _ => None, + }) + .collect(); + on.sort_unstable(); + on +} + /// A point where a tangency must happen: an end point shared by the two curves, or a line end -/// point constrained onto the round curve. -fn touch_point(sk: &Sketch, k: &ConstraintKind) -> Option { +/// point constrained onto the round curve (`on_curve` from [`points_on_curves`]). +pub(crate) fn touch_point(sk: &Sketch, k: &ConstraintKind, on_curve: &[(usize, usize)]) -> Option { let ConstraintKind::Tangent { a, b } = *k else { return None }; let ends = |c: usize| -> Vec { match sk.curves.get(c).map(|c| &c.kind) { @@ -627,111 +681,105 @@ fn touch_point(sk: &Sketch, k: &ConstraintKind) -> Option { if let Some(p) = ea.iter().find(|p| eb.contains(p)) { return Some(*p); } - let on = |p: usize, c: usize| sk.constraints.iter().any(|x| x.kind == ConstraintKind::PointOnCurve { p, c }); + let on = |p: usize, c: usize| on_curve.binary_search(&(p, c)).is_ok(); ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))).copied() } -/// Variables a block depends on. -fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { +/// Variables a block depends on, and whether it reads a point that is a constant of the solve. +fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> (Vec, bool) { let mut v = Vec::new(); - let pt = |v: &mut Vec, p: usize| { - if let Some(x) = lay.pvar.get(p).copied().flatten() { + let mut constant = false; + // Adds a point's variables; true when it is a constant. + let pt = |v: &mut Vec, p: usize| match lay.pvar.get(p).copied().flatten() { + Some(x) => { v.extend([x, x + 1]); + false } + None => true, }; - let curve = |v: &mut Vec, c: usize| { - if let Some(k) = sk.curves.get(c).map(|c| &c.kind) { - for q in k.point_ids() { - pt(v, q); - } - if let Some(r) = lay.rvar.get(c).copied().flatten() { - v.push(r); - } - } - }; - use ConstraintKind::*; - match b { - Block::Closure { a, b } => { - pt(&mut v, *a); - pt(&mut v, *b); - } - Block::Arc { c, a, b } => { - pt(&mut v, *c); - pt(&mut v, *a); - pt(&mut v, *b); - } - Block::User(_, k) => match **k { - Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => { - pt(&mut v, p); - pt(&mut v, q); - } - Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => { - pt(&mut v, p); - pt(&mut v, q); - } - PointOnCurve { p, c } | Midpoint { p, l: c } | PointLineDistance { p, l: c, .. } => { - pt(&mut v, p); - curve(&mut v, c); - } - Horizontal { l } - | Vertical { l } - | Length { l, .. } - | LineAngle { l, .. } - | ArcSweep { c: l, .. } - | Radius { c: l, .. } - | Diameter { c: l, .. } => curve(&mut v, l), - Parallel { a, b } - | Perpendicular { a, b } - | Collinear { a, b } - | Tangent { a, b } - | Equal { a, b } - | Concentric { a, b } - | Angle { a, b, .. } - | SlotOverallLength { a, b, .. } => { - curve(&mut v, a); - curve(&mut v, b); - } - Symmetric { p, q, l } => { - pt(&mut v, p); - pt(&mut v, q); - curve(&mut v, l); - } - Fix { .. } => {} - Smooth { a, b } => { - curve(&mut v, a); - curve(&mut v, b); - } - ArcLength { c, .. } => curve(&mut v, c), - LinearDiameter { p, l, .. } => { - pt(&mut v, p); - curve(&mut v, l); - } - }, - Block::TangentAt(_, k, p) => { - pt(&mut v, *p); - if let ConstraintKind::Tangent { a, b } = **k { - curve(&mut v, a); - curve(&mut v, b); + for r in block_refs(sk, b) { + let (c, fits): (usize, fn(&CurveKind) -> bool) = match r { + Ref::Point(p) => { + constant |= pt(&mut v, p); + continue; } + Ref::Curve(c) => (c, |_| true), + Ref::Line(c) => (c, |k| matches!(k, CurveKind::Line { .. })), + Ref::Round(c) => (c, |k| matches!(k, CurveKind::Circle { .. } | CurveKind::Arc { .. })), + }; + // A curve of another kind reads as a constant (see `State::line`, `State::round`). + let Some(k) = sk.curves.get(c).map(|c| &c.kind).filter(|k| fits(k)) else { + constant = true; + continue; + }; + for q in k.point_ids() { + constant |= pt(&mut v, q); } + v.extend(lay.rvar.get(c).copied().flatten()); } v.sort_unstable(); v.dedup(); - v + (v, constant) } -fn find(parent: &mut [usize], mut i: usize) -> usize { - while let Some(&p) = parent.get(i) { - if p == i { - break; +/// What a block reads: a point, or a curve (its points and its radius), of any kind or read +/// as a line or as a circle or arc (the residual reads another kind as a constant). +#[derive(Clone, Copy)] +enum Ref { + Point(usize), + Curve(usize), + Line(usize), + Round(usize), +} + +/// What a block reads. +fn block_refs(sk: &Sketch, b: &Block) -> Vec { + use ConstraintKind::*; + use Ref::{Curve as C, Line as L, Point as P, Round as R}; + let is = |c: usize, f: fn(&CurveKind) -> bool| sk.curves.get(c).is_some_and(|x| f(&x.kind)); + let line = |c: usize| is(c, |k| matches!(k, CurveKind::Line { .. })); + let freeform = |c: usize| is(c, CurveKind::is_freeform); + // A tangency reads a free-form curve as such, else a line and a round, or two rounds. + let tangent = |a: usize, b: usize| { + if freeform(a) || freeform(b) { + [C(a), C(b)] + } else if line(a) { + [L(a), R(b)] + } else if line(b) { + [R(a), L(b)] + } else { + [R(a), R(b)] } - let gp = parent.get(p).copied().unwrap_or(p); - if let Some(slot) = parent.get_mut(i) { - *slot = gp; - } - i = p; + }; + match b { + Block::Closure { a, b } => vec![P(*a), P(*b)], + Block::Arc { c, a, b } => vec![P(*c), P(*a), P(*b)], + Block::User(_, k) => match **k { + Coincident { p, q } + | HorizontalPoints { p, q } + | VerticalPoints { p, q } + | Distance { p, q, .. } + | DistanceX { p, q, .. } + | DistanceY { p, q, .. } => vec![P(p), P(q)], + PointOnCurve { p, c } => vec![P(p), C(c)], + Midpoint { p, l } | PointLineDistance { p, l, .. } | LinearDiameter { p, l, .. } => vec![P(p), L(l)], + Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => vec![L(l)], + // Another kind reads a constant value, whatever its points: as good as any. + ArcSweep { c, .. } | Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => vec![C(c)], + Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Angle { a, b, .. } => vec![L(a), L(b)], + Equal { a, b } if line(a) => vec![L(a), L(b)], + Equal { a, b } | Concentric { a, b } | SlotOverallLength { a, b, .. } => vec![R(a), R(b)], + Tangent { a, b } => tangent(a, b).to_vec(), + Smooth { a, b } => vec![C(a), C(b)], + Symmetric { p, q, l } => vec![P(p), P(q), L(l)], + Fix { .. } => vec![], + }, + Block::TangentAt(_, k, p) => match **k { + ConstraintKind::Tangent { a, b } if freeform(a) || freeform(b) => vec![P(*p), C(a), C(b)], + ConstraintKind::Tangent { a, b } => [P(*p)].into_iter().chain(tangent(a, b)).collect(), + _ => vec![P(*p)], + }, } - i } /// Residual vector and Jacobian (rows × all n vars) for the given blocks. @@ -834,15 +882,31 @@ fn norm2(v: &[f64]) -> f64 { v.iter().map(|x| x * x).sum() } -/// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns iterations. -fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], x: &mut Vec, col: &[usize]) -> usize { +/// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns the +/// iterations and the largest residual left. +fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], x: &mut Vec, col: &[usize]) -> (usize, f64) { + lm_capped(sk, lay, blocks, vars, x, col, MAX_ITERS) +} + +/// [`lm`] with at most `max_iters` iterations. +fn lm_capped( + sk: &Sketch, + lay: &Layout, + blocks: &[(Block, Vec)], + vars: &[usize], + x: &mut Vec, + col: &[usize], + max_iters: usize, +) -> (usize, f64) { let n = vars.len(); + #[cfg(test)] + lm_vars::add(n); let mut lambda = 1e-3; let (mut r, _, _) = system(sk, lay, blocks, x, false); let mut f = norm2(&r); - for it in 0..MAX_ITERS { + for it in 0..max_iters { if max_abs(&r) < TOL { - return it; + return (it, max_abs(&r)); } let (_, j, _) = local_system(sk, lay, blocks, x, col, n); // Normal equations of this component. @@ -896,22 +960,741 @@ fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], } } if !improved { - return it; + return (it, max_abs(&r)); } } - MAX_ITERS + (max_iters, max_abs(&r)) +} + +/// The variables LM was given and its runs on this thread, for tests that count what the +/// construction leaves to it. +#[cfg(test)] +pub(crate) mod lm_vars { + use std::cell::Cell; + + thread_local! { + static VARS: Cell = const { Cell::new(0) }; + static RUNS: Cell = const { Cell::new(0) }; + } + + pub(super) fn add(n: usize) { + VARS.with(|c| c.set(c.get() + n)); + RUNS.with(|c| c.set(c.get() + 1)); + } + + /// The variables since the last call. + pub(crate) fn take() -> usize { + VARS.with(|c| c.replace(0)) + } + + /// The LM runs since the last call. + pub(crate) fn runs() -> usize { + RUNS.with(|c| c.replace(0)) + } +} + +/// The pinned analysis is exact (see [`pinned`]); in this crate's tests every analysis is +/// checked against the dense analysis of the whole component wherever that one is reliable: +/// its Jacobian finite, and its answer the same from a thousandth to a thousand times its +/// tolerance. (Where it is not, the Jacobian is nearly singular and the two analyses may +/// decide its rank differently: the dense one on the whole matrix, the pinned one on the +/// remainder after eliminating what the constraints pin down, and which pivots fall under the +/// tolerance depends on that order.) +#[cfg(test)] +fn assert_dense_agrees(j: &Mat, rank: usize, determined: &[bool]) { + let at = |tol: f64| if j.rows > 0 { determined_vars(j, tol) } else { (vec![false; j.cols], 0) }; + let dense = at(1e-7); + if j.data.iter().any(|v| !v.is_finite()) || at(1e-10) != dense || at(1e-4) != dense { + return; + } + assert_eq!((rank, determined), (dense.1, dense.0.as_slice()), "the pinned rank analysis disagrees with the dense one"); +} + +/// Least squared singular value the rows pinning an unknown must have on its own columns +/// (a singular value of 1e-4, a thousand times the rank analysis' pivot tolerance)… +const PIN_RANK: f64 = 1e-8; +/// …and the least ratio of its squared singular values (a condition number of at most 1e4), so +/// that the 2 × 2 test is decided well clear of rounding. +const PIN_CONDITION: f64 = 1e-8; + +/// What [`pinned`] works for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pin { + /// The solve: the candidates are where the seed constructed them, and every row that pins + /// one must hold there (below `TOL`), as LM will not move it. + Verified, + /// The rank analysis of a component some block ties to a constant. + Anchored, + /// The rank analysis of a component no block ties to a constant: the point whose first + /// variable this is counts as a constant (it takes out the translation, see `pinned`). + Floating(usize), +} + +impl Pin { + /// The first variable of the point taken as a constant. + fn anchor(self) -> Option { + match self { + Pin::Floating(a) => Some(a), + Pin::Verified | Pin::Anchored => None, + } + } +} + +/// Map `keep` to columns `0..` and the other variables of `all` to none. +fn columns(col: &mut [usize], keep: &[usize], all: &[usize]) { + for v in all { + if let Some(c) = col.get_mut(*v) { + *c = usize::MAX; + } + } + for (k, v) in keep.iter().enumerate() { + if let Some(c) = col.get_mut(*v) { + *c = k; + } + } +} + +/// The variables of `vars` neither pinned nor of the point at `anchor` (sorted, as `vars`). +fn unpinned(vars: &[usize], pin: &[bool], anchor: Option) -> Vec { + let anchored = |v: usize| anchor.is_some_and(|a| v == a || v == a + 1); + vars.iter().zip(pin).filter(|(v, p)| !**p && !anchored(**v)).map(|(v, _)| *v).collect() +} + +/// The blocks that read one of `vars` (sorted). +fn blocks_reading<'a>(blocks: &[(Block<'a>, Vec)], vars: &[usize]) -> Vec<(Block<'a>, Vec)> { + blocks.iter().filter(|(_, bv)| bv.iter().any(|v| vars.binary_search(v).is_ok())).map(|(b, v)| (copy_block(b), v.clone())).collect() +} + +/// Which variables of one component (`vars`, sorted; `blocks` its blocks) the constraints pin +/// down one unknown after another: an unknown is pinned when the rows of the blocks that read +/// nothing else but constants and unknowns pinned before it have full column rank on its own +/// columns (well conditioned: `PIN_RANK`, `PIN_CONDITION`). Only `candidate` unknowns are +/// pinned; for [`Pin::Verified`] also only where those rows hold. +/// +/// Why this is exact. Order the pinned unknowns as they were pinned, P, and call the rest R. +/// Each pinned unknown has its rows S_u, which read nothing after it in P and nothing in R, so +/// the rows S = ∪ S_u of the Jacobian restricted to the columns of P form a block lower +/// triangular matrix whose diagonal blocks have full column rank, and they are zero on R. For +/// any vector (p, r) in the null space of J, S gives p = 0, so the null space is +/// {(0, r) : J_R r = 0}, where J_R is J's columns of R (every row, those of S included, are zero +/// there). Hence: +/// - rank J = |P| + rank J_R: the analysis eliminates P by construction and runs the dense rank +/// analysis on J_R only; +/// - every pinned variable is determined (zero in every null vector), and a variable of R is +/// determined in J exactly when it is in J_R. +/// +/// For the solve, the same structure means the pinned values are the only ones near them that +/// satisfy S, given the constants: freezing them at verified values and solving R with LM finds +/// a solution of the whole system whenever one exists with P where the seed put it. (When LM +/// cannot solve R that way, the component is solved whole, as without pinning.) +/// +/// A component no block ties to a constant ([`Pin::Floating`]) is invariant under +/// translation: every residual depends only on differences of its points (and radii, which a +/// translation leaves alone), so the translations t_x, t_y are null vectors of J. Removing one +/// point a's two columns then keeps the rank, as any null vector of J is a null vector of J +/// without a (all of whose entries on a are zero) plus a translation: rank J = rank J_{-a}, and +/// J_{-a} is analysed as above with a as a constant. The translations move every point, so no +/// point coordinate of such a component is determined; a radius is determined when it is in +/// J_{-a} (the translations leave it alone). +fn pinned( + sk: &Sketch, + lay: &Layout, + blocks: &[(Block, Vec)], + vars: &[usize], + x: &mut [f64], + candidate: &dyn Fn(usize) -> bool, + pin: Pin, +) -> Vec { + let nv = vars.len(); + let local = |v: usize| vars.binary_search(&v).ok(); + // Per local unknown (by its first variable's index): pinned, or the anchor. + let mut done = vec![false; nv]; + if let Some(a) = pin.anchor().and_then(local).and_then(|a| done.get_mut(a)) { + *a = true; + } + let block_units: Vec> = blocks + .iter() + .map(|(_, bv)| { + let mut u: Vec = bv.iter().filter_map(|v| local(lay.unit(*v))).collect(); + u.dedup(); + u + }) + .collect(); + let mut of_unit: Vec> = vec![Vec::new(); nv]; + for (bi, us) in block_units.iter().enumerate() { + for u in us { + if let Some(list) = of_unit.get_mut(*u) { + list.push(bi); + } + } + } + let open = |done: &[bool], u: &usize| !done.get(*u).copied().unwrap_or(true); + // Per block: its unknowns not pinned yet. A block is ready for its last one. + let mut pending: Vec = block_units.iter().map(|us| us.iter().filter(|u| open(&done, u)).count()).collect(); + let mut ready: Vec = (0..blocks.len()).filter(|bi| pending.get(*bi) == Some(&1)).rev().collect(); + let mut gram = vec![[0.0_f64; 3]; nv]; + let mut bad = vec![false; nv]; + let mut queue = Vec::new(); + let mut out = vec![false; nv]; + loop { + while let Some(bi) = ready.pop() { + let (Some(us), Some((block, _))) = (block_units.get(bi), blocks.get(bi)) else { continue }; + let Some(&u) = us.iter().find(|u| open(&done, u)) else { continue }; + let Some(&first) = vars.get(u) else { continue }; + if bad.get(u).copied().unwrap_or(true) || !candidate(first) { + continue; + } + let Some(g) = gram.get_mut(u) else { continue }; + let residual = accumulate(sk, lay, block, &[first, first + 1][..lay.width(first)], x, g); + if pin == Pin::Verified + && !(residual < TOL) + && let Some(b) = bad.get_mut(u) + { + *b = true; + continue; + } + queue.push(u); + } + let Some(u) = queue.pop() else { break }; + let (Some(&first), Some(&g)) = (vars.get(u), gram.get(u)) else { continue }; + let w = lay.width(first); + if !open(&done, &u) || bad.get(u).copied().unwrap_or(true) || !full_rank(g, w) { + continue; + } + for k in u..u + w { + if let (Some(d), Some(o)) = (done.get_mut(k), out.get_mut(k)) { + (*d, *o) = (true, true); + } + } + for &bi in of_unit.get(u).into_iter().flatten() { + if let Some(p) = pending.get_mut(bi) { + *p = p.saturating_sub(1); + if *p == 1 { + ready.push(bi); + } + } + } + } + out +} + +/// Add the rows of block `b`, restricted to the columns of `unit` (one or two variables), to +/// `gram` (their AᵀA: [a00, a01, a11]), with the same differences as [`local_system`]; returns +/// the block's largest residual. +fn accumulate(sk: &Sketch, lay: &Layout, b: &Block, unit: &[usize], x: &mut [f64], gram: &mut [f64; 3]) -> f64 { + let mut r = Vec::new(); + block_residuals(&State { sk, lay, x }, b, &mut r); + let mut d = [Vec::new(), Vec::new()]; + let (mut plus, mut minus) = (Vec::new(), Vec::new()); + for (&v, dk) in unit.iter().zip(&mut d) { + let Some(x0) = x.get(v).copied() else { continue }; + let h = 1e-7 * x0.abs().max(1.0); + for (at, out) in [(x0 + h, &mut plus), (x0 - h, &mut minus)] { + if let Some(xv) = x.get_mut(v) { + *xv = at; + } + out.clear(); + block_residuals(&State { sk, lay, x }, b, out); + } + if let Some(xv) = x.get_mut(v) { + *xv = x0; + } + *dk = plus.iter().zip(&minus).map(|(p, m)| (p - m) / (2.0 * h)).collect(); + } + let [d0, d1] = &d; + for k in 0..r.len() { + let (a, b) = (d0.get(k).copied().unwrap_or(0.0), d1.get(k).copied().unwrap_or(0.0)); + gram[0] += a * a; + gram[1] += a * b; + gram[2] += b * b; + } + max_abs(&r) +} + +/// The rows behind `gram` (see [`accumulate`]) have full, well-conditioned column rank on a +/// unit of `width` variables. +fn full_rank(gram: [f64; 3], width: usize) -> bool { + let [a, b, c] = gram; + let (min, max) = if width == 1 { + (a, a) + } else { + let max = 0.5 * (a + c + ((a - c).powi(2) + 4.0 * b * b).sqrt()); + (if max > 0.0 { (a * c - b * b) / max } else { 0.0 }, max) + }; + min.is_finite() && max.is_finite() && min >= PIN_RANK && min >= PIN_CONDITION * max } /// Solve the sketch in place. On failure the geometry is restored and the report lists the /// violated constraints. pub fn solve(sk: &mut Sketch) -> SolveReport { - solve_holding(sk, &Hold::default()) + solve_with(sk, &Hold::default(), None, Analysis::Full, None) } /// Solve with some geometry held as if it were fixed (for this solve only): applying a /// constraint between two entities holds the first so only the second moves. Everything else /// moves as little as it can. On failure the geometry is restored. pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { + solve_with(sk, hold, None, Analysis::Full, None) +} + +/// What a solve works out besides placing the geometry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Analysis { + /// Degrees of freedom and which geometry the constraints determine: a rank analysis of the + /// Jacobian, the costly part of solving an already solved sketch. + Full, + /// Only whether it solved: the report's `dof` is 0 and its determined flags are empty. For + /// solves whose report is only checked with [`SolveReport::ok`] (the frames of a drag). + StatusOnly, +} + +/// [`solve`] with some geometry held as if it were fixed (for this solve only): applying a +/// constraint between two entities holds the first so only the second moves; a drag holds what +/// it drags. Everything else moves as little as it can. On failure the geometry is restored. +/// +/// `reference`, after an interactive move: the points' positions from before it. Points the +/// constraints pin down keep the orientation they had there rather than the one the move left +/// them in. Points appended after the reference was taken use their current positions; a +/// reference longer than the point list is ignored. +/// +/// `budget`: the work the seed's cluster decomposition may do, shared with other solves (the +/// steps of a live drag frame, [`Budget::frame`]); [`Budget::full`] for this solve alone when +/// `None`. +pub fn solve_with(sk: &mut Sketch, hold: &Hold, reference: Option<&[Vec2]>, analysis: Analysis, budget: Option<&mut Budget>) -> SolveReport { + let mut own = Budget::full(); + solve_from(sk, hold, reference, analysis, None, budget.unwrap_or(&mut own)) +} + +/// Driving values by constraint id (see [`Sketch::driving_values`]). +pub type DrivingValues = std::collections::BTreeMap; + +/// [`solve`] after driving values changed: the sketch's geometry is its stored solved state, +/// solved for `old` (by constraint id; constraints added since have none). A component the +/// construction leaves to the iteration (a triconnected one) can fail to converge, or fold a +/// constrained triangle over, when solved for the new values in one jump; it is then solved +/// again by stepping its changed values from old to new (see `continue_values`). Where no +/// value changed a solve behaves as [`solve`]. +pub fn solve_after_value_edit(sk: &mut Sketch, old: &DrivingValues) -> SolveReport { + let old_values: Vec> = sk.constraints.iter().map(|c| old.get(&c.id).copied()).collect(); + solve_from(sk, &Hold::default(), None, Analysis::Full, Some(&old_values), &mut Budget::full()) +} + +impl Sketch { + /// Each dimension's driving value by constraint id, as [`solve_after_value_edit`] takes + /// them. + pub fn driving_values(&self) -> DrivingValues { + self.constraints.iter().filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect() + } +} + +#[cfg(not(test))] +fn solve_from( + sk: &mut Sketch, + hold: &Hold, + reference: Option<&[Vec2]>, + analysis: Analysis, + old_values: Option<&[Option]>, + budget: &mut Budget, +) -> SolveReport { + solve_inner(sk, hold, reference, analysis, old_values, budget) +} + +/// In this crate's tests every solve is repeated without the constructive seed: the seed only +/// chooses where the iteration starts, so status, degrees of freedom and determined flags must +/// not depend on it, nor the (empty) failing list of a solved sketch. The one difference +/// allowed is the seed's purpose: the iteration from the plain start fails where the seeded +/// one solves. Which constraints a failed solve leaves violated depends on where the iteration +/// stopped; the seed may only narrow it. +#[cfg(test)] +fn solve_from( + sk: &mut Sketch, + hold: &Hold, + reference: Option<&[Vec2]>, + analysis: Analysis, + old_values: Option<&[Option]>, + budget: &mut Budget, +) -> SolveReport { + use crate::construct::switch; + if !(switch::seed_on() && switch::differential_on()) { + return solve_inner(sk, hold, reference, analysis, old_values, budget); + } + let mut plain = sk.clone(); + let without = switch::without_seed(|| solve_inner(&mut plain, hold, reference, analysis, old_values, &mut Budget::full())); + let with = solve_inner(sk, hold, reference, analysis, old_values, budget); + assert_same_outcome(&with, &without); + with +} + +/// The outcome the constructive seed must not change (see `solve_from`). +#[cfg(test)] +fn assert_same_outcome(with: &SolveReport, without: &SolveReport) { + if with.ok() && !without.ok() { + return; + } + assert_eq!( + (with.status, with.dof, &with.point_determined, &with.curve_determined), + (without.status, without.dof, &without.point_determined, &without.curve_determined), + "the constructive seed changed the solve's outcome" + ); + // Turned-over orientations depend on where the iteration went; only conflicts count. + let conflicts = |r: &SolveReport| r.failing.iter().filter(|c| !r.folded.contains(c)).cloned().collect::>(); + if with.ok() { + assert_eq!(conflicts(with), conflicts(without)); + } else { + assert!(conflicts(with).iter().all(|c| without.failing.contains(c)), "seeded {with:?}, unseeded {without:?}"); + } +} + +/// Write a point's position into its two variables starting at `v`. +/// An arc's ends follow its centre: when the seed moved the centre and did not construct an end, +/// that end starts carried by the centre's displacement (the arc translates, keeping its radius). +/// Left behind, the ends of an arc whose centre is put on the origin are turned by LM instead of +/// following it (reference test 20-slot-constrained). +fn carry_with_seeded(x: &mut [f64], x0: &[f64], constructed: &[bool], blocks: &[(Block, Vec)], lay: &Layout) { + let var = |p: usize| lay.pvar.get(p).copied().flatten(); + let built = |v: usize| constructed.get(v).copied().unwrap_or(false); + let mut carried: Vec<(usize, Vec2)> = Vec::new(); + for (block, _) in blocks { + let Block::Arc { c, a, b } = *block else { continue }; + let Some(vc) = var(c).filter(|v| built(*v)) else { continue }; + let (Some(now), Some(was)) = (x.get(vc..vc + 2), x0.get(vc..vc + 2)) else { continue }; + let d = Vec2::new(now[0] - was[0], now[1] - was[1]); + if d.x == 0.0 && d.y == 0.0 { + continue; + } + carried.extend([a, b].into_iter().filter_map(var).filter(|v| !built(*v)).map(|v| (v, d))); + } + for (v, d) in carried { + if let Some([px, py]) = x.get_mut(v..v + 2) { + *px += d.x; + *py += d.y; + } + } +} + +fn set_point(x: &mut [f64], v: usize, at: Vec2) { + if let Some([sx, sy]) = x.get_mut(v..v.saturating_add(2)) { + *sx = at.x; + *sy = at.y; + } +} + +/// Exactly opposite directions sit on the wrapped angle's derivative discontinuity: nudge one +/// free endpoint of each such line into a continuous branch before Newton iterations. +fn nudge_line_angles(sk: &Sketch, lay: &Layout, x: &mut [f64]) { + for constraint in &sk.constraints { + if constraint.driven { + continue; + } + let ConstraintKind::LineAngle { l, value } = constraint.kind else { continue }; + let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { continue }; + let state = State { sk, lay, x }; + let (pa, pb) = (state.p(*a), state.p(*b)); + let delta = wrap_angle((pb - pa).angle() - value); + if delta.abs() < std::f64::consts::PI - 1e-5 || !delta.is_finite() { + continue; + } + let d = Vec2::from_angle((pb - pa).angle() - delta.signum() * 0.01) * pa.dist(pb); + let (point, at) = if lay.pvar.get(*b).copied().flatten().is_some() { (*b, pa + d) } else { (*a, pb - d) }; + if let Some(index) = lay.pvar.get(point).copied().flatten() { + set_point(x, index, at); + } + } +} + +/// Continuation steps an edit in sixty-fourths of it: the smallest sub-step is one of them. +const SUBSTEPS: usize = 64; +/// LM iterations one continuation may spend over all its sub-steps (taken and halved). +const CONTINUATION_ITERS: usize = 4 * MAX_ITERS; +/// Triangles of distance dimensions whose turn one component's fold check reads. +const MAX_TRIPLES: usize = 1024; +/// Neighbour visits while finding those triangles. +const MAX_TRIPLE_WORK: usize = 100_000; + +/// An orientation the constraints leave open, which an edit of their values must keep: the +/// mirror image is "the other solution" (Owen 1991 §5: whether three dimensioned points occur +/// clockwise or counter-clockwise; an unsigned offset's side). +#[derive(Clone, Copy, Debug)] +enum Orient { + /// The turn of three points pairwise joined by distances (coincident corners merged). + Turn([usize; 3]), + /// Which side of `p` the point `q` is along x (`DistanceX` of the given size). + SideX(usize, usize, f64), + /// The same along y (`DistanceY`). + SideY(usize, usize, f64), + /// Which side of line `l` the point `p` is on (`PointLineDistance`, `LinearDiameter`). + OfLine(usize, usize, f64), +} + +/// An orientation's signed offset in a state (a corner's distance from the opposite side, an +/// offset along its axis or from its line), the size it is measured against and how far the +/// coordinates are from the origin. +fn offset(s: &State, o: Orient) -> Option<(f64, f64, f64)> { + let coords = |ps: &[Vec2]| ps.iter().fold(0.0_f64, |m, p| m.max(crate::loci::mag(*p))); + Some(match o { + Orient::Turn([a, b, c]) => { + let (pa, pb, pc) = (s.p(a), s.p(b), s.p(c)); + let size = pa.dist(pb).max(pa.dist(pc)).max(pb.dist(pc)); + ((pb - pa).normalized()?.cross(pc - pa), size, coords(&[pa, pb, pc])) + } + Orient::SideX(p, q, size) => { + let (pp, pq) = (s.p(p), s.p(q)); + (pq.x - pp.x, size, coords(&[pp, pq])) + } + Orient::SideY(p, q, size) => { + let (pp, pq) = (s.p(p), s.p(q)); + (pq.y - pp.y, size, coords(&[pp, pq])) + } + Orient::OfLine(p, l, size) => { + let (a, b) = s.line(l); + let pp = s.p(p); + ((b - a).normalized()?.cross(pp - a), size, coords(&[pp, a, b])) + } + }) +} + +/// The sign of an orientation in a state; `None` when too close to zero to tell. It is the +/// constructive seed's side test (`loci::side`, the rounding of the coordinates and a tiny +/// share of the size), so the seed and the fold check agree on which way a triangle turns. +/// Belongs in `loci` beside `side` (moved there once the lines-and-rounds work is merged). +fn orientation(s: &State, o: Orient) -> Option { + offset(s, o).and_then(|(delta, size, coords)| crate::loci::side(delta, size, coords)) +} + +/// A reference orientation must be clear: an offset of at least this share of its size (for a +/// triangle, a height of 1/1000 of its longest side, about 0.06°). Nearer degenerate, solver +/// noise decides the sign, and no edit is refused for turning it over. +const ORIENTED: f64 = 1e-3; + +/// The orientation of the stored state that an edit must keep: its sign where it is clear +/// (see [`ORIENTED`]), else `None`. +fn reference_orientation(s: &State, o: Orient) -> Option { + offset(s, o).filter(|(delta, size, _)| delta.abs() > ORIENTED * size).and_then(|(delta, size, coords)| crate::loci::side(delta, size, coords)) +} + +/// A driving value `t` of the way from `old` to `new`: angles of a line or between two lines +/// turn the short way round. +fn value_at(kind: &ConstraintKind, old: f64, new: f64, t: f64) -> f64 { + if t >= 1.0 { + return new; + } + match kind { + ConstraintKind::LineAngle { .. } | ConstraintKind::Angle { .. } => old + wrap_angle(new - old) * t, + _ => old + (new - old) * t, + } +} + +/// Whether a value can be stepped from `old` to `new`: not a signed arc sweep that changes +/// sign (the arc's end points swap; there is no path of arcs between the two). +fn steppable(kind: &ConstraintKind, old: f64, new: f64) -> bool { + !(matches!(kind, ConstraintKind::ArcSweep { .. }) && old * new <= 0.0) +} + +/// One component's driving values changed by an edit, and the orientations of the stored +/// state. +struct ValueEdit { + /// Block index, constraint index, the constraint (with its new value) and its old value. + changed: Vec<(usize, usize, ConstraintKind, f64)>, + /// Orientations with the constraints that set them and their sign in the stored state. + orients: Vec<(Orient, Vec, Option)>, +} + +impl ValueEdit { + /// The values of `sub` changed against `old` (per constraint), `before` being the stored + /// state; `None` when nothing changed. + fn of(before: &State, sub: &[(Block, Vec)], old: &[Option]) -> Option { + let changed: Vec<_> = sub + .iter() + .enumerate() + .filter_map(|(k, (b, _))| { + let Block::User(ci, kind) = b else { return None }; + let (o, n) = (old.get(*ci).copied().flatten()?, kind.value()?); + (o.is_finite() && n.is_finite() && o != n).then(|| (k, *ci, (*kind).clone(), o)) + }) + .collect(); + if changed.is_empty() { + return None; + } + let orients = orients(before, sub).into_iter().map(|(o, cs)| (o, cs, reference_orientation(before, o))).collect(); + Some(ValueEdit { changed, orients }) + } + + /// The stored state is solved for the old values: there is a solved state to continue + /// from and orientations to keep (a sketch stored unsolved has neither). + fn starts_solved(&self, before: &State, sub: &[(Block, Vec)]) -> bool { + let olds = self.kinds_at(0); + let mut blocks: Vec<(Block, Vec)> = sub.iter().map(|(b, v)| (copy_block(b), v.clone())).collect(); + self.point_at(&mut blocks, &olds); + max_abs(&system(before.sk, before.lay, &blocks, &mut before.x.to_vec(), false).0) < ACCEPT + } + + /// The values can be stepped (see [`steppable`]). + fn continuable(&self) -> bool { + self.changed.iter().all(|(_, _, k, o)| k.value().is_some_and(|n| steppable(k, *o, n))) + } + + /// The changed constraints `j` sixty-fourths of the way from their old values to the new. + fn kinds_at(&self, j: usize) -> Vec { + let t = j as f64 / SUBSTEPS as f64; + self.changed + .iter() + .map(|(_, _, kind, o)| { + let mut k = kind.clone(); + if let Some(n) = kind.value() { + k.set_value(value_at(kind, *o, n, t)); + } + k + }) + .collect() + } + + /// Point the changed constraints' blocks at `kinds` (as [`ValueEdit::kinds_at`] gives them). + fn point_at<'a>(&self, blocks: &mut [(Block<'a>, Vec)], kinds: &'a [ConstraintKind]) { + for ((k, ci, ..), kind) in self.changed.iter().zip(kinds) { + if let Some(slot) = blocks.get_mut(*k) { + slot.0 = Block::User(*ci, kind); + } + } + } + + /// The constraints setting an orientation of the stored state that `now` turns the other way. + fn flipped<'a>(&'a self, now: &'a State) -> impl Iterator + 'a { + self.orients.iter().filter(|(o, _, before)| before.zip(orientation(now, *o)).is_some_and(|(b, n)| b != n)).flat_map(|(_, cs, _)| cs) + } + + /// Solved, keeping every orientation of the stored state. + fn holds(&self, residual: f64, now: &State) -> bool { + residual < ACCEPT && self.flipped(now).next().is_none() + } +} + +/// The orientations the driving constraints of `sub` leave open: triangles of distances and +/// line lengths (coincident points count as one, the corners of a triangle of lines), and the +/// sides of unsigned offsets. Each with the constraints that set it; capped. +fn orients(s: &State, sub: &[(Block, Vec)]) -> Vec<(Orient, Vec)> { + use std::collections::{BTreeMap, BTreeSet}; + let free = |p: usize| s.lay.pvar.get(p).copied().flatten().is_some(); + let line = |l: usize| match s.sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => Some((*a, *b)), + _ => None, + }; + let mut out = Vec::new(); + let mut joins = Vec::new(); + let mut edges = Vec::new(); + for (b, _) in sub { + let Block::User(ci, kind) = b else { continue }; + match **kind { + ConstraintKind::Coincident { p, q } => joins.push((p, q)), + ConstraintKind::Distance { p, q, .. } => edges.push((p, q, *ci)), + ConstraintKind::Length { l, .. } => edges.extend(line(l).map(|(p, q)| (p, q, *ci))), + ConstraintKind::DistanceX { p, q, value } if free(p) || free(q) => out.push((Orient::SideX(p, q, value), vec![*ci])), + ConstraintKind::DistanceY { p, q, value } if free(p) || free(q) => out.push((Orient::SideY(p, q, value), vec![*ci])), + ConstraintKind::PointLineDistance { p, l, value } => out.push((Orient::OfLine(p, l, value), vec![*ci])), + ConstraintKind::LinearDiameter { p, l, value } => out.push((Orient::OfLine(p, l, value / 2.0), vec![*ci])), + _ => {} + } + } + // Union-find over the points these constraints name (not the whole sketch). + let mut local: BTreeMap = BTreeMap::new(); + for (p, q) in joins.iter().copied().chain(edges.iter().map(|&(p, q, _)| (p, q))) { + for i in [p, q] { + let n = local.len(); + local.entry(i).or_insert(n); + } + } + let mut parent: Vec = (0..local.len()).collect(); + let at = |i: usize| local.get(&i).copied().unwrap_or(0); + for &(p, q) in &joins { + union(&mut parent, at(p), at(q)); + } + // Each group stands for the first of its points met; it is free when any of them is. + let mut rep: BTreeMap = BTreeMap::new(); + let mut free_groups: BTreeSet = BTreeSet::new(); + let mut adj: BTreeMap> = BTreeMap::new(); + for &(p, q, ci) in &edges { + let [a, b] = [p, q].map(|i| { + let r = *rep.entry(find(&mut parent, at(i))).or_insert(i); + if free(i) { + free_groups.insert(r); + } + r + }); + if a != b { + adj.entry(a).or_default().entry(b).or_insert(ci); + adj.entry(b).or_default().entry(a).or_insert(ci); + } + } + let mut work = 0usize; + let mut triangles = 0usize; + for (&a, na) in &adj { + for (&b, &ab) in na.range(a + 1..) { + let Some(nb) = adj.get(&b) else { continue }; + for (&c, &ac) in na.range(b + 1..) { + work += 1; + if work > MAX_TRIPLE_WORK || triangles >= MAX_TRIPLES { + return out; + } + if let Some(&bc) = nb.get(&c) + && [a, b, c].iter().any(|p| free_groups.contains(p)) + { + triangles += 1; + out.push((Orient::Turn([a, b, c]), vec![ab, bc, ac])); + } + } + } + } + out +} + +/// Dimension continuation: solve the component `sub` from `x` (its previous solved state) for +/// its changed values stepped from old to new, each sub-step from the previous solution. A +/// sub-step that fails or flips an orientation of the previous state is halved; the step +/// doubles again after one that holds. Gives up below one [`SUBSTEPS`]th of the edit or after +/// [`CONTINUATION_ITERS`] iterations. Returns the iterations and whether the new values were +/// reached; `x` holds the last sub-step's solution either way. +fn continue_values( + sk: &Sketch, + lay: &Layout, + sub: &[(Block, Vec)], + vars: &[usize], + col: &[usize], + x: &mut [f64], + edit: &ValueEdit, +) -> (usize, bool) { + // Every sub-step lands on a sixty-fourth: the changed constraints at each, built once. + let arena: Vec> = (0..=SUBSTEPS).map(|j| edit.kinds_at(j)).collect(); + let mut blocks: Vec<(Block, Vec)> = sub.iter().map(|(b, v)| (copy_block(b), v.clone())).collect(); + let (mut t, mut h, mut spent) = (0, SUBSTEPS / 2, 0); + let mut trial = x.to_vec(); + while t < SUBSTEPS && spent < CONTINUATION_ITERS { + let next = (t + h).min(SUBSTEPS); + if let Some(kinds) = arena.get(next) { + edit.point_at(&mut blocks, kinds); + } + trial.copy_from_slice(x); + let (its, residual) = lm_capped(sk, lay, &blocks, vars, &mut trial, col, MAX_ITERS.min(CONTINUATION_ITERS - spent)); + spent += its.max(1); + if edit.holds(residual, &State { sk, lay, x: &trial }) { + x.copy_from_slice(&trial); + t = next; + h = (2 * h).min(SUBSTEPS); + } else if h > 1 { + h /= 2; + } else { + break; + } + } + (spent, t >= SUBSTEPS) +} + +fn solve_inner( + sk: &mut Sketch, + hold: &Hold, + reference: Option<&[Vec2]>, + analysis: Analysis, + old_values: Option<&[Option]>, + budget: &mut Budget, +) -> SolveReport { let mut orientation_before = Vec::new(); for constraint in &sk.constraints { if !constraint.driven @@ -930,12 +1713,7 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { let mut x = vec![0.0; lay.n]; for (i, p) in sk.points.iter().enumerate() { if let Some(v) = lay.pvar.get(i).copied().flatten() { - if let Some(s) = x.get_mut(v) { - *s = p.pos.x; - } - if let Some(s) = x.get_mut(v + 1) { - *s = p.pos.y; - } + set_point(&mut x, v, p.pos); } } for (i, c) in sk.curves.iter().enumerate() { @@ -946,72 +1724,91 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { } } let x0 = x.clone(); - // Exactly opposite directions sit on the wrapped angle's derivative discontinuity. - // Nudge one free endpoint into a continuous branch before Newton iterations. The - // untouched x0 still restores the complete original geometry on a failed solve. - for constraint in &sk.constraints { - if constraint.driven { - continue; - } - let ConstraintKind::LineAngle { l, value } = constraint.kind else { continue }; - let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { continue }; - let (pa, pb) = { - let state = State { sk, lay: &lay, x: &x }; - (state.p(*a), state.p(*b)) - }; - let delta = wrap_angle((pb - pa).angle() - value); - if delta.abs() < std::f64::consts::PI - 1e-5 || !delta.is_finite() { - continue; - } - let d = Vec2::from_angle((pb - pa).angle() - delta.signum() * 0.01) * pa.dist(pb); - let (point, at) = if lay.pvar.get(*b).copied().flatten().is_some() { (*b, pa + d) } else { (*a, pb - d) }; - if let Some(index) = lay.pvar.get(point).copied().flatten() { - if let Some(v) = x.get_mut(index) { - *v = at.x; + let on_curve = points_on_curves(sk); + // Start from the constructed positions of the points the constraints pin down (see + // `construct`); LM still verifies every constraint from there. A touching placement does + // not pin a point down well and is left where it is (`Placement::anchors`). `unseeded` + // keeps the start the solve would have without the seed, for the variables the seed moved: + // a component the seeded start cannot solve is solved again from there, and only if the + // seed moved its start. + let mut unseeded = None; + // Per variable: the seed constructed it (a candidate to stay where it was put, see `pinned`). + let mut constructed = vec![false; lay.n]; + if crate::construct::switch::seed_on() { + let given: Vec = lay.pvar.iter().map(Option::is_none).collect(); + let seeded = crate::construct::seed(sk, &given, reference, &on_curve, budget); + for (i, (at, how)) in seeded.pos.iter().zip(&seeded.placed_by).enumerate() { + if let (Some(at), Some(how), Some(v)) = (at, how, lay.pvar.get(i).copied().flatten()) + && how.anchors() + { + set_point(&mut x, v, *at); + // A floated cluster's pose is a guess at freedom the constraints leave: LM's + // start, never pinned. + if how.builds() + && let Some(c) = constructed.get_mut(v..v + 2) + { + c.fill(true); + } } - if let Some(v) = x.get_mut(index + 1) { - *v = at.y; + } + for (r, v) in seeded.radius.iter().zip(&lay.rvar) { + if let (Some(r), Some(v)) = (r, v) + && let Some(slot) = x.get_mut(*v) + { + *slot = *r; + if let Some(c) = constructed.get_mut(*v) { + *c = true; + } } } + if x != x0 { + let mut plain = x0.clone(); + nudge_line_angles(sk, &lay, &mut plain); + unseeded = Some(plain); + } } + nudge_line_angles(sk, &lay, &mut x); - let mut blocks: Vec<(Block, Vec)> = Vec::new(); + // The blocks, and per block whether it reads a constant point. + let mut raw = Vec::new(); for c in &sk.curves { if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind && let (Some(a), Some(b)) = (pts.first(), pts.last()) && a != b { - let bl = Block::Closure { a: *a, b: *b }; - let vars = block_vars(sk, &lay, &bl); - blocks.push((bl, vars)); + raw.push(Block::Closure { a: *a, b: *b }); } if let CurveKind::Arc { c, a, b } = c.kind { - let bl = Block::Arc { c, a, b }; - let vars = block_vars(sk, &lay, &bl); - blocks.push((bl, vars)); + raw.push(Block::Arc { c, a, b }); } } for (i, c) in sk.constraints.iter().enumerate() { if c.driven { continue; } - let bl = match touch_point(sk, &c.kind) { + raw.push(match touch_point(sk, &c.kind, &on_curve) { Some(p) => Block::TangentAt(i, &c.kind, p), None => Block::User(i, &c.kind), - }; - let vars = block_vars(sk, &lay, &bl); - blocks.push((bl, vars)); + }); } + let mut blocks: Vec<(Block, Vec)> = Vec::with_capacity(raw.len()); + let mut reads_constant = Vec::with_capacity(raw.len()); + for bl in raw { + let (vars, constant) = block_vars(sk, &lay, &bl); + blocks.push((bl, vars)); + reads_constant.push(constant); + } + + carry_with_seeded(&mut x, &x0, &constructed, &blocks, &lay); + // The plain start, for the variables whose start the seed (or carrying along it) moved. + let unseeded = unseeded.map(|plain: Vec| plain.into_iter().zip(&x).map(|(p, s)| (p != *s).then_some(p)).collect::>()); // Connected components over variables. let mut parent: Vec = (0..lay.n).collect(); for (_, vars) in &blocks { if let Some(&first) = vars.first() { for &v in vars.iter().skip(1) { - let (ra, rb) = (find(&mut parent, first), find(&mut parent, v)); - if let Some(p) = parent.get_mut(ra) { - *p = rb; - } + union(&mut parent, first, v); } } } @@ -1043,26 +1840,124 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { None => constant_blocks.push(bi), } } - // Column of each variable within its component. + // Column of each variable in the system being solved or analysed (usize::MAX outside it). let mut col = vec![usize::MAX; lay.n]; - for (_, vars, _) in &comps { - for (k, v) in vars.iter().enumerate() { - if let Some(c) = col.get_mut(*v) { - *c = k; - } - } - } let mut iterations = 0; - // Solve each component; `blocks` is consumed by index so rebuild per component. + let mut continuations = 0; + let mut folded: Vec = Vec::new(); let all_blocks = blocks; let oversized = |vars: &[usize]| vars.len() > MAX_COMPONENT_VARS; + let blocks_of = |bis: &mut dyn Iterator| -> Vec<(Block, Vec)> { + bis.filter_map(|bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect() + }; for (_, vars, bis) in &comps { if bis.is_empty() || oversized(vars) { continue; } - let sub: Vec<(Block, Vec)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); - iterations += lm(sk, &lay, &sub, vars, &mut x, &col); + let sub = blocks_of(&mut bis.iter().copied()); + let start: Vec = vars.iter().filter_map(|v| x.get(*v).copied()).collect(); + let mut residual = max_abs(&system(sk, &lay, &sub, &mut x, false).0); + // Already solved (by the seed, or before): nothing to iterate. + if !(residual < TOL) { + columns(&mut col, vars, vars); + // What the seed constructed and the constraints pin down stays where the seed put it + // (see `pinned`); LM solves the rest with those values as constants. + let (mut pinned_failed, mut solved) = (false, false); + if vars.iter().any(|v| constructed.get(*v).copied().unwrap_or(false)) { + let candidate = |v: usize| constructed.get(v).copied().unwrap_or(false); + let pin = pinned(sk, &lay, &sub, vars, &mut x, &candidate, Pin::Verified); + if pin.contains(&true) { + let rest = unpinned(vars, &pin, None); + let rest_blocks = blocks_reading(&sub, &rest); + columns(&mut col, &rest, vars); + let (its, r) = if rest.is_empty() { (0, 0.0) } else { lm(sk, &lay, &rest_blocks, &rest, &mut x, &col) }; + iterations += its; + residual = r; + solved = residual < ACCEPT; + if !solved { + // The rest cannot be solved with the pinned values: the whole component is. + for (&v, s0) in vars.iter().zip(&start) { + if let Some(s) = x.get_mut(v) { + *s = *s0; + } + } + columns(&mut col, vars, vars); + pinned_failed = true; + } + } + } + // A seeded start can leave LM stuck where the plain start solves (a partly + // constructed component): the seeded solution is kept whenever it solves, otherwise a + // component the seed touched is solved again from where it would have started without + // the seed. After a failed pinned solve, the whole component goes straight to that + // start (the seeded one is where the pinned solve failed), or to its own start when + // the seed did not move it. + let reseeded = unseeded.as_ref().filter(|plain| vars.iter().any(|v| plain.get(*v).is_some_and(Option::is_some))); + if !solved && !(pinned_failed && reseeded.is_some()) { + let (its, r) = lm(sk, &lay, &sub, vars, &mut x, &col); + iterations += its; + residual = r; + } + if !(residual < ACCEPT) + && let Some(plain) = reseeded + { + for (&v, s0) in vars.iter().zip(&start) { + if let Some(s) = x.get_mut(v) { + *s = plain.get(v).copied().flatten().unwrap_or(*s0); + } + } + let (its, r) = lm(sk, &lay, &sub, vars, &mut x, &col); + iterations += its; + residual = r; + } + } + // Continuation (below) solves the whole component: the pinned values belong to the new + // values, not to the ones it steps through. + columns(&mut col, vars, vars); + // After an edit of driving values from a solved state, a component the one-jump solve + // left unsolved, or turned over (an orientation of that state flipped), is solved by + // continuation from there. Its result replaces the one-jump result only when it solves + // and keeps every orientation; a solved result that still flips one is refused. + if let Some(old) = old_values + && let Some(edit) = ValueEdit::of(&State { sk, lay: &lay, x: &x0 }, &sub, old) + && !edit.holds(residual, &State { sk, lay: &lay, x: &x }) + && edit.starts_solved(&State { sk, lay: &lay, x: &x0 }, &sub) + { + if edit.continuable() { + continuations += 1; + // This component's variables restart from the previous state; the components + // solved before it keep their solutions (LM here touches only `vars`). + let mut from = x.clone(); + for &v in vars { + if let (Some(s), Some(s0)) = (from.get_mut(v), x0.get(v)) { + *s = *s0; + } + } + let (its, mut done) = continue_values(sk, &lay, &sub, vars, &col, &mut from, &edit); + iterations += its; + if !done { + // The last sub-step's solution, solved for the new values in one jump. + let (its, r) = lm(sk, &lay, &sub, vars, &mut from, &col); + iterations += its; + done = edit.holds(r, &State { sk, lay: &lay, x: &from }); + } + if done { + x = from; + residual = 0.0; + } + } + if residual < ACCEPT { + let now = State { sk, lay: &lay, x: &x }; + for ci in edit.flipped(&now) { + if let Some(c) = sk.constraints.get(*ci) + && !folded.contains(&c.id) + { + folded.push(c.id.clone()); + } + } + } + } } // Final residuals and analysis, component by component (the Jacobian is block diagonal: @@ -1091,24 +1986,48 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { if oversized(vars) { continue; } - let sub: Vec<(Block, Vec)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); + let sub = blocks_of(&mut bis.iter().copied()); if sub.is_empty() { continue; } - let (r, j, owner) = local_system(sk, &lay, &sub, &mut x, &col, vars.len()); + let (r, _, owner) = system(sk, &lay, &sub, &mut x, false); check(&r, &owner, &sub, &mut failing); - if j.rows > 0 { - let (d, rk) = determined_vars(&j, 1e-7); - rank += rk; - for (k, v) in vars.iter().enumerate() { - if let (Some(slot), Some(flag)) = (det.get_mut(*v), d.get(k)) { - *slot = *flag; - } + if analysis == Analysis::StatusOnly { + continue; + } + // The rank analysis runs on what `pinned` leaves: see there for why that is exact. + let floating = bis.iter().all(|bi| !reads_constant.get(*bi).copied().unwrap_or(true)); + let pin = match vars.iter().copied().find(|v| lay.is_point(*v)) { + Some(anchor) if floating => Pin::Floating(anchor), + _ => Pin::Anchored, + }; + let pin_flags = pinned(sk, &lay, &sub, vars, &mut x, &|_| true, pin); + let rest = unpinned(vars, &pin_flags, pin.anchor()); + columns(&mut col, &rest, vars); + let (_, j, _) = local_system(sk, &lay, &blocks_reading(&sub, &rest), &mut x, &col, rest.len()); + let (rest_det, rest_rank) = if j.rows > 0 { determined_vars(&j, 1e-7) } else { (vec![false; rest.len()], 0) }; + let comp_rank = pin_flags.iter().filter(|p| **p).count() + rest_rank; + rank += comp_rank; + for (&v, pinned) in vars.iter().zip(&pin_flags) { + let flag = match rest.binary_search(&v) { + Ok(k) => rest_det.get(k).copied().unwrap_or(false), + Err(_) => *pinned, + }; + // A floating component's translation moves every point. + let moves = matches!(pin, Pin::Floating(_)) && lay.is_point(v); + if let Some(slot) = det.get_mut(v) { + *slot = flag && !moves; } } + #[cfg(test)] + { + columns(&mut col, vars, vars); + let (_, j, _) = local_system(sk, &lay, &sub, &mut x, &col, vars.len()); + let reduced: Vec = vars.iter().map(|v| det.get(*v).copied().unwrap_or(false)).collect(); + assert_dense_agrees(&j, comp_rank, &reduced); + } } - let consts: Vec<(Block, Vec)> = - constant_blocks.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect(); + let consts = blocks_of(&mut constant_blocks.iter().copied()); if !consts.is_empty() { let (r, _, owner) = system(sk, &lay, &consts, &mut x, false); check(&r, &owner, &consts, &mut failing); @@ -1126,10 +2045,7 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { && *a < parent.len() && *b < parent.len() { - let (a, b) = (find(&mut parent, *a), find(&mut parent, *b)); - if let Some(slot) = parent.get_mut(b) { - *slot = a; - } + union(&mut parent, *b, *a); } } let mut representatives = std::collections::BTreeMap::new(); @@ -1171,6 +2087,11 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { None }; let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed }; + for id in &folded { + if !failing.contains(id) { + failing.push(id.clone()); + } + } let use_x = if status == SolveStatus::Solved { &x } else { &x0 }; // Write back. for (i, p) in sk.points.iter_mut().enumerate() { @@ -1207,7 +2128,7 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { None => true, }; // Driven dimensions measure the solved geometry. - let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 }; + let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; let measured: Vec> = sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect(); for (c, m) in sk.constraints.iter_mut().zip(measured) { @@ -1217,20 +2138,96 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { c.kind.set_value(v); } } - let point_determined: Vec = (0..sk.points.len()).map(pdet).collect(); - let curve_determined = - sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect(); + let (dof, point_determined, curve_determined) = match analysis { + Analysis::Full => ( + lay.n.saturating_sub(rank), + (0..sk.points.len()).map(pdet).collect(), + sk.curves.iter().enumerate().map(|(i, c)| c.kind.point_ids().into_iter().all(pdet) && vdet(lay.rvar.get(i).copied().flatten())).collect(), + ), + Analysis::StatusOnly => (0, Vec::new(), Vec::new()), + }; SolveReport { status, - dof: lay.n.saturating_sub(rank), + dof, iterations, max_residual: if max_residual.is_finite() { max_residual } else { f64::MAX }, failing, + folded, + continuations, point_determined, curve_determined, } } +#[cfg(test)] +mod continuation_unit_tests { + use super::*; + + #[test] + fn angles_step_the_short_way_round() { + let line = ConstraintKind::LineAngle { l: 0, value: 0.0 }; + let (old, new) = (179f64.to_radians(), (-179f64).to_radians()); + let half = value_at(&line, old, new, 0.5); + assert!((half - 180f64.to_radians()).abs() < 1e-12, "{}", half.to_degrees()); + let first = value_at(&line, old, new, 1.0 / SUBSTEPS as f64); + assert!((first - old).abs() < 2f64.to_radians() / 32.0, "a sixty-fourth of 2°, not of 358°"); + assert_eq!(value_at(&line, old, new, 1.0), new); + let distance = ConstraintKind::Distance { p: 0, q: 1, value: 0.0 }; + assert_eq!(value_at(&distance, 10.0, 20.0, 0.5), 15.0); + } + + #[test] + fn a_sweep_changing_sign_is_not_stepped() { + let sweep = ConstraintKind::ArcSweep { c: 0, value: 0.0 }; + assert!(!steppable(&sweep, 1.0, -1.0)); + assert!(steppable(&sweep, 1.0, 2.0)); + assert!(steppable(&ConstraintKind::LineAngle { l: 0, value: 0.0 }, 3.0, -3.0)); + } + + #[test] + fn only_clear_orientations_are_kept() { + // A triangle flat to solver noise has a side by the seed's test but none to keep. + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap(); + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let turn = Orient::Turn([a, b, c]); + let state = + |sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn)); + assert_eq!(state(&sk), (Some(1.0), None)); + sk.points[c].pos = Vec2::new(20.0, 0.1); + assert_eq!(state(&sk), (Some(1.0), Some(1.0))); + // A side offset well below its dimension's size is no reference either. + let side = Orient::SideX(a, c, 20.0); + sk.points[c].pos = Vec2::new(1e-6, 5.0); + assert_eq!(reference_orientation(&State { sk: &sk, lay: &lay, x: &[] }, side), None); + } + + #[test] + fn offsets_have_a_side() { + let mut sk = Sketch::new(); + let p = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap(); + let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap(); + let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap(); + let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 }; + let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o); + let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])]; + let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect(); + assert!(before.iter().all(Option::is_some), "{before:?}"); + // q mirrored through p and below the line: every side and the turn flip. + sk.points[q].pos = Vec2::new(-10.0, -15.0); + let after: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect(); + for (b, a) in before.iter().zip(&after) { + assert_eq!(b.zip(*a).map(|(b, a)| b == a), Some(false), "{before:?} {after:?}"); + } + // On the line: no side to tell. + sk.points[q].pos = Vec2::new(0.0, -5.0); + assert_eq!(sign(&sk, Orient::OfLine(q, l, 10.0)), None); + } +} + #[cfg(test)] mod typed_dimension_tests { use super::*; @@ -1250,6 +2247,41 @@ mod typed_dimension_tests { assert_eq!(sk.points, points); } + /// A constraint read on a curve of the wrong kind (a midpoint of an arc, pushed past + /// validation) reads constants for it: its component is tied to them, not floating, and the + /// pinned analysis agrees with the dense one (checked in every test solve). + #[test] + fn a_curve_read_as_another_kind_ties_its_component() { + let mut sk = Sketch::new(); + let arc = sk.add_arc(Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0), Vec2::new(5.0, 15.0), [None, None, None], None).unwrap(); + let p = sk.add_point(Vec2::new(3.0, 1.0), None).unwrap(); + sk.constraints.push(crate::model::Constraint { + id: "m".into(), + kind: ConstraintKind::Midpoint { p, l: arc }, + param: None, + driven: false, + text: None, + }); + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + assert!(rep.point_determined[p], "the midpoint of the constant line: {rep:?}"); + assert_eq!(rep.dof, 5, "the arc's centre and ends, less its equal radii: {rep:?}"); + } + + #[test] + fn a_status_only_solve_places_the_geometry_as_a_full_one() { + let mut sk = Sketch::new(); + let l = sk.add_line(Vec2::ZERO, Vec2::new(10.0, 3.0), Some(0), None, None).unwrap(); + sk.add_constraint(ConstraintKind::LineAngle { l, value: 0.5 }, None).unwrap(); + let mut quick = sk.clone(); + let full = solve(&mut sk); + let status = solve_with(&mut quick, &Hold::default(), None, Analysis::StatusOnly, None); + assert!(full.ok() && status.ok()); + assert_eq!(quick, sk); + assert_eq!(full.dof, 1, "the end slides along the line"); + assert_eq!((status.dof, status.point_determined.len(), status.curve_determined.len()), (0, 0, 0)); + } + #[test] fn signed_dimensions_validate_kinds_values_and_survive_curve_removal() { let mut sk = Sketch::new(); diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 4953dce6..b43252f2 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -4,10 +4,44 @@ use solvecraft_geom::Vec2; use crate::*; -fn v(x: f64, y: f64) -> Vec2 { +pub(crate) fn v(x: f64, y: f64) -> Vec2 { Vec2::new(x, y) } +/// Deterministic pseudo-random numbers for the solver harnesses. +pub(crate) struct Lcg(pub u64); + +impl Lcg { + pub(crate) fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + self.next() * (hi - lo) + } + /// ±1, evenly. + pub(crate) fn sign(&mut self) -> f64 { + if self.next() < 0.5 { 1.0 } else { -1.0 } + } +} + +/// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for +/// tests that measure the difference). +pub(crate) fn seeded(on: bool, f: impl FnOnce() -> T) -> T { + use crate::construct::switch; + switch::without_differential(|| if on { f() } else { switch::without_seed(f) }) +} + +/// The points that are constants for a plain solve. +pub(crate) fn given(sk: &Sketch) -> Vec { + crate::solver::held_points(sk, &Hold::default()) +} + +/// The constructive seed of a plain solve. +pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed { + crate::construct::seed(sk, &given(sk), None, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full()) +} + /// Rough rectangle from four lines sharing corner points. fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] { let a = sk.add_point(p[0], None).unwrap(); diff --git a/crates/sketch/src/union_find.rs b/crates/sketch/src/union_find.rs new file mode 100644 index 00000000..f155bf3b --- /dev/null +++ b/crates/sketch/src/union_find.rs @@ -0,0 +1,43 @@ +//! Disjoint sets over indices (`parent[i]` is `i` for a root), shared by the solver's +//! components, profile regions, the model's point groups and dimension placement's chains. + +/// The root of `i`'s set, halving the path on the way. An index outside `parent` is its own +/// root. +pub(crate) fn find(parent: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parent.get(i) { + if p == i { + break; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(i) { + *slot = gp; + } + i = p; + } + i +} + +/// Join the sets of `a` and `b`. +pub(crate) fn union(parent: &mut [usize], a: usize, b: usize) { + let (ra, rb) = (find(parent, a), find(parent, b)); + if let Some(slot) = parent.get_mut(ra) { + *slot = rb; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn joins_and_halves_paths() { + let mut p: Vec = (0..6).collect(); + union(&mut p, 0, 1); + union(&mut p, 1, 2); + union(&mut p, 4, 5); + assert_eq!(find(&mut p, 0), find(&mut p, 2)); + assert_ne!(find(&mut p, 0), find(&mut p, 4)); + assert_eq!(find(&mut p, 3), 3); + assert_eq!(find(&mut p, 99), 99); + } +} diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 7abbd6de..33a30c49 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use crate::link::{Link, LinkGeom, LinkKind, LinkSource}; -use crate::model::{Result, Sketch, SketchError}; +use crate::model::{Result, Sketch, SketchError, in_range3}; /// Most points one wire may have. pub const MAX_WIRE_POINTS: usize = 100_000; @@ -97,7 +97,7 @@ fn clean(w: &[Vec3]) -> Option> { } let mut out: Vec = Vec::new(); for p in w { - if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) { + if !in_range3(*p) { return None; } if out.last().is_none_or(|l| l.dist(*p) > 1e-9) { @@ -118,14 +118,14 @@ fn drawn_polyline(fit: &[Vec3]) -> Result> { if fit.is_empty() || fit.len() > MAX_FIT_POINTS { return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); } - if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + if fit.iter().any(|p| !in_range3(*p)) { return Err(SketchError::Invalid("3D point out of range".into())); } if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); } let pts = fit_polyline(fit); - if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + if pts.iter().any(|p| !in_range3(*p)) { return Err(SketchError::Invalid("3D spline samples out of range".into())); } Ok(pts) @@ -163,7 +163,7 @@ impl Sketch { || w.pts.len() > MAX_WIRE_POINTS || w.fit.len() > MAX_FIT_POINTS || (w.pts.len() == 1 && w.fit.len() != 1) - || w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) + || w.pts.iter().chain(&w.fit).any(|p| !in_range3(*p)) { return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into())); } @@ -241,7 +241,7 @@ impl Sketch { /// Move a fit point of a drawn 3D curve, and every other drawn point at the same place /// (curves drawn end to end stay joined). Returns how many points moved. pub fn move_drawn_point(&mut self, wire: &str, index: usize, to: Vec3) -> Result { - if !(to.is_finite() && to.x.abs() < 1e9 && to.y.abs() < 1e9 && to.z.abs() < 1e9) { + if !in_range3(to) { return Err(SketchError::Invalid("3D point out of range".into())); } let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?; diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs index 6988be14..39b1ac88 100644 --- a/crates/ui-egui/src/agent_cursor.rs +++ b/crates/ui-egui/src/agent_cursor.rs @@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec { // A profile feature without picks takes the profiles of the active (else the last) sketch: // point at them. if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") { - let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty())); + let ss = + app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s)); if let Some(ss) = ss { out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point()))); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f67eb70d..c101db7f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne let id = egui::Id::new(("pexpr", &p.name)); let mut e = ui.data(|dd| dd.get_temp::(id)).unwrap_or_else(|| p.expr.clone()); let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0)); + let refused_id = egui::Id::new(("prefused", &p.name)); if r.changed() { - ui.data_mut(|dd| dd.insert_temp(id, e.clone())); + // Typing again clears the reason a previous change was refused. + ui.data_mut(|dd| { + dd.insert_temp(id, e.clone()); + dd.remove::(refused_id); + }); } if r.lost_focus() && e != p.expr { - let _ = app.run("parameters.change", json!({"name": p.name, "expression": e})); - ui.data_mut(|dd| dd.remove::(id)); + match change_param(app, &p.name, &e) { + Ok(()) => ui.data_mut(|dd| { + dd.remove::(id); + dd.remove::(refused_id); + }), + // Refused (it would leave a sketch unsolved, say): the typed expression + // stays in the field with the reason beside it, nothing silently reverts. + Err(why) => ui.data_mut(|dd| { + dd.insert_temp(refused_id, why); + }), + } } + let refused = ui.data(|dd| dd.get_temp::(refused_id)); let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) }); - match (v, errs.get(&p.name)) { + match (v, errs.get(&p.name).or(refused.as_ref())) { (_, Some(e)) => ui.label(RichText::new(e).color(t.error)), (Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)), _ => ui.label(""), @@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne ui.end_row(); } +/// Set a parameter's expression from the parameters table: `Err` with the message to show +/// beside the field when the change is refused (the document keeps its values). +pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> { + app.run("parameters.change", json!({"name": name, "expression": expression})) + .map(|_| ()) + .map_err(|e| crate::friendly_error("parameters.change", &e)) +} + /// Start a sketch on a picked plane or planar face and turn the view to face it. pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { let plane = match sel { @@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option bool { } /// Start dragging the text of the dimension under `press` (true: a dimension took the drag). -pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool { +/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`). +pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool { let Some(d) = DRAWN.with(|d| { - let id = hit(press)?; + let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?; d.borrow().iter().find(|x| x.id == id).cloned() }) else { return false; diff --git a/crates/ui-egui/src/drag_snap.rs b/crates/ui-egui/src/drag_snap.rs index 078c21f6..f165f789 100644 --- a/crates/ui-egui/src/drag_snap.rs +++ b/crates/ui-egui/src/drag_snap.rs @@ -7,7 +7,7 @@ use std::cell::{Cell, RefCell}; use egui::{Align2, FontId, Shape, Stroke}; -use serde_json::json; +use serde_json::{Value, json}; use solvecraft_engine::geom::Vec2; use solvecraft_engine::sketch::{CurveKind, Sketch}; @@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec, Vec) { (own, ex) } -/// The drag let go: the constraint of the snap it ended on (none for lining up). -pub fn release(app: &mut SolveApp) { - let Some(s) = current() else { return }; +/// The drag let go: the constraint of the snap it ended on (none for lining up), as +/// `sketch.drag_end` takes it. +pub fn release() -> Option { + let s = current()?; clear(); let (cmd, params) = match &s.target { Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})), Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})), Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})), - Target::Align => return, + Target::Align => return None, }; - // A snap that would over-constrain the sketch is simply not kept. - let _ = app.session.execute(cmd, ¶ms); + Some(json!({"command": cmd, "params": params})) } /// The snap of the drag in progress: its dashed guides, a ring on the target and its label. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3caa1c5c..6e007d72 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,6 +93,8 @@ pub(crate) mod sketch_display; #[cfg(test)] mod sketch_display_ui_tests; #[cfg(test)] +mod sketch_drag_ui_tests; +#[cfg(test)] mod sketch_edit_tests; pub mod sketch_palette; pub(crate) mod sketch_text; diff --git a/crates/ui-egui/src/sketch_drag_ui_tests.rs b/crates/ui-egui/src/sketch_drag_ui_tests.rs new file mode 100644 index 00000000..f94209d0 --- /dev/null +++ b/crates/ui-egui/src/sketch_drag_ui_tests.rs @@ -0,0 +1,107 @@ +//! Dragging sketch geometry in the viewport: live frames re-solve the sketch only, letting go +//! brings the model up to date in the drag's one undo step, and the grab stays on the geometry. + +use serde_json::{Value, json}; +use solvecraft_engine::geom::Vec2; + +use crate::scenario::Harness; + +fn run(h: &mut Harness, id: &str, p: Value) { + if let Err(e) = h.app.run(id, p.clone()) { + panic!("{id} {p}: {e}"); + } +} + +fn screen(h: &mut Harness, x: f64, y: f64) -> egui::Pos2 { + let [sx, sy] = h.at(&json!({"sketch": [x, y]})).expect("on screen"); + egui::pos2(sx as f32, sy as f32) +} + +fn point(h: &Harness, id: &str) -> Vec2 { + let sid = h.app.session.active_sketch.expect("sketch mode"); + let st = h.app.session.world_state(); + let sk = &st.sketch(sid).expect("the sketch").sketch; + sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}")) +} + +/// Press at `path[0]` and move through the rest, one frame per event. +fn press_and_move(h: &mut Harness, path: &[egui::Pos2]) { + let Some(&a) = path.first() else { return }; + h.app.synthetic.push(egui::Event::PointerMoved(a)); + h.app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.app.synthetic.extend(path.iter().skip(1).map(|&p| egui::Event::PointerMoved(p))); + play(h); +} + +fn release(h: &mut Harness, at: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + play(h); +} + +fn play(h: &mut Harness) { + while !h.app.synthetic.is_empty() { + h.frame(); + } + h.frames(2); +} + +/// Points from `a` to `b` in `n` equal moves (without `a`). +fn steps(a: egui::Pos2, b: egui::Pos2, n: usize) -> Vec { + (1..=n).map(|k| a + (b - a) * (k as f32 / n as f32)).collect() +} + +#[test] +fn a_viewport_drag_solves_the_sketch_live_and_rebuilds_the_model_on_release() { + let mut h = Harness::new(); + run(&mut h, "sketch.create", json!({"plane": "XY"})); + run(&mut h, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})); + run(&mut h, "sketch.dimension", json!({"entities": ["l1"], "value": 20})); + run(&mut h, "sketch.dimension", json!({"entities": ["l2"], "value": 15})); + run(&mut h, "sketch.finish", json!({})); + run(&mut h, "solid.extrude", json!({"distance": 5})); + let sketch = h.app.session.doc.features.first().map(|f| f.id).expect("the sketch"); + h.app.edit_sketch(sketch); + h.settle(); + let undo = h.app.session.undo.len(); + let a = screen(&mut h, 5.0, 5.0); + let b = screen(&mut h, -3.0, -6.0); + let mut path = vec![a]; + path.extend(steps(a, b, 8)); + press_and_move(&mut h, &path); + assert!(h.app.viewport.point_drag.is_some(), "the drag is under way"); + assert!(h.app.session.model.is_stale(), "the frames updated the sketch only"); + assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1, "{:?}", point(&h, "l1.start")); + release(&mut h, b); + assert!(h.app.viewport.point_drag.is_none()); + assert!(!h.app.session.model.is_stale(), "letting go evaluated the model"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for the drag"); + assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1); + run(&mut h, "edit.undo", json!({})); + assert!(point(&h, "l1.start").dist(Vec2::new(5.0, 5.0)) < 1e-9); +} + +#[test] +fn a_dragged_line_that_stops_short_stays_under_the_cursor_when_it_can_follow_again() { + let mut h = Harness::new(); + run(&mut h, "sketch.create", json!({"plane": "XY"})); + // Two 10 mm links from the origin hold the start of a horizontal 10 mm line: the line can + // slide right until its start is 20 mm from the origin (3 mm). + let (x, y) = (15.0, 75f64.sqrt()); + run(&mut h, "sketch.line", json!({"points": [[0, 0], [10, 0], [x, y], [x + 10.0, y]]})); + for l in ["l1", "l2", "l3"] { + run(&mut h, "sketch.dimension", json!({"entities": [l], "value": 10})); + } + run(&mut h, "sketch.constraint.horizontal_vertical", json!({"line": "l3"})); + let grab = screen(&mut h, x + 5.0, y); + let far = screen(&mut h, x + 11.0, y); + let back = screen(&mut h, x + 7.0, y); + let mut path = vec![grab]; + path.extend(steps(grab, far, 8)); + path.extend(steps(far, back, 4)); + press_and_move(&mut h, &path); + release(&mut h, back); + // The cursor went 6 mm right, the line only 3; back at 2 mm right it is under it again. + let start = point(&h, "l3.start"); + assert!(start.dist(Vec2::new(x + 2.0, y)) < 0.1, "{start:?}"); + assert!(!h.app.session.model.is_stale()); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 373a4f79..085745d9 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1887,13 +1887,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let (o, d) = proj.ray(p); ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) }; - // The 3D triad, then a dimension's text, follow a drag that starts on them. - if resp.drag_started_by(egui::PointerButton::Primary) - && let Some(a) = ui.input(|i| i.pointer.press_origin()) - && !crate::sketch3d::drag_start(app, &proj, a) - && let Some(q) = on_plane(app, a) + // What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry + // (before the dimension's lines, which end on the geometry they measure), as for clicks. + let grabbed = resp + .drag_started_by(egui::PointerButton::Primary) + .then(|| ui.input(|i| i.pointer.press_origin())) + .flatten() + .filter(|a| !crate::sketch3d::drag_start(app, &proj, *a)) + .map(|a| (a, pick(app, &proj, a).into_iter().next())); + if let Some((a, hit)) = &grabbed + && let Some(q) = on_plane(app, *a) { - crate::dim_view::drag_start(app, a, q); + let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })); + crate::dim_view::drag_start(app, *a, q, on_geometry); } if crate::sketch3d::dragging() && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) @@ -1908,13 +1914,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::dim_view::drag_to(app, q); } // A point or a curve of the sketch being edited follows a drag that starts on it. - if resp.drag_started_by(egui::PointerButton::Primary) + if let Some((a, hit)) = grabbed && !crate::dim_view::dragging() && !crate::sketch3d::dragging() - && let Some(a) = ui.input(|i| i.pointer.press_origin()) { let active = app.session.active_sketch; - match pick(app, &proj, a).into_iter().next() { + match hit { Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => { app.viewport.point_drag = Some((id, app.session.undo.len(), None)) } @@ -1950,17 +1955,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(s) = snap { q += s.offset; } - if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() { - app.session.undo.truncate(depth + 1); - } + // Each frame re-solves the sketch only (`live`); the model follows when the drag ends. + let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) { + Ok(r) => { + app.session.undo.truncate(depth + 1); + r.get("at").and_then(solvecraft_engine::params::vec2).unwrap_or(q) + } + Err(_) => from, + }; + // The grab stays on the geometry: what it could not follow this frame it catches up + // with on the next one instead of lagging the cursor for the rest of the drag. if grab.is_some() { - app.viewport.point_drag = Some((id, depth, Some(q))); + app.viewport.point_drag = Some((id, depth, Some(at))); } } if resp.drag_stopped() { if let Some((_, depth, _)) = app.viewport.point_drag.clone() { - // Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step. - crate::drag_snap::release(app); + // Letting go on a point, midpoint or curve adds the matching constraint, then the + // whole model follows the drag, all in the drag's undo step. + let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() })); app.session.undo.truncate(depth + 1); } crate::drag_snap::clear(); @@ -1969,6 +1982,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::sketch3d::drag_end(); } } + // A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here, + // so the model does not stay behind its sketch until the next command. Not while CAD input + // is held back (an issue report's capture): a release then belongs to the report, and the + // drag continues afterwards. + if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) { + app.viewport.point_drag = None; + crate::drag_snap::clear(); + let _ = app.session.execute("sketch.drag_end", &json!({})); + } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() && primary_nav.is_none() diff --git a/docs/perf.md b/docs/perf.md index 7baa026b..713337bb 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -285,3 +285,51 @@ Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodi full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after 300 s. Its window continued handling inspection and Cancel, and cancelling preserved the case. Complex STEP construction remains an outstanding kernel performance problem. + +# Sketch solver and drags (2026-10-11) + +Unknowns the constraints pin down one after another from the constants (each one's own rows +of full, well-conditioned rank, reading nothing but constants and unknowns pinned before it) +are eliminated exactly: values the constructive seed placed and whose constraints hold there +stay constants while Levenberg–Marquardt solves the rest, and the rank analysis (degrees of +freedom, determined geometry) runs on the remainder only. A component tied to no constant takes +one point as a constant first (its translation). All the held solves of one live drag frame +share a 1M decomposition work budget (about 16 ms; a solve's own, 20M, is about 0.3 s), and a +drag's steps are a quarter of the smallest held size instead of an eighth, except once a seed +ran out of budget: the rest of that drag goes in eighths, as before, and continuation keeps +the branch the seed no longer places. + +`cargo run --release -p solvecraft-sketch --example solve_bench`, macOS arm64, load average +45–75 (best of 5): + +| Grid (origin corner fixed, rows horizontal, columns vertical, every edge a distance) | Start | Before | After | +|---|---|---:|---:| +| 10 × 10 | at solution | 0.36 ms | 0.21 ms | +| 10 × 10 | ±1 mm | 0.34 ms | 0.21 ms | +| 15 × 15 | at solution | 1.41 ms | 0.41 ms | +| 15 × 15 | ±1 mm | 1.39 ms | 0.40 ms | +| 20 × 20 | at solution | 4.06 ms | 0.72 ms | +| 20 × 20 | ±1 mm | 4.44 ms | 0.73 ms | + +The seed constructs every grid point, so LM gets no variable (a test counts them). Before, the +time was the dense rank analysis of the whole grid. A 100-rectangle strip with nothing fixed, +solved whole from a moved corner, still costs 20–27 ms: nothing is constructed there and LM +iterates on every point with dense normal equations (a sparse solve would be the next step). + +Drag frames, `sketch.drag` + `world_state()` per frame (60 frames of 0.5 mm, best of 5; the +"before" column is one run of the same bench on the previous code): + +| Sketch | Points | Scripted ms/frame before → after | Live ms/frame before → after | `drag_end` ms before → after | +|---|---:|---:|---:|---:| +| 100 rectangles | 401 | 2.77 → 2.37 | 1.13 → 1.02 | 1.44 → 1.37 | +| 100 rectangles, rolled back under an extrude | 401 | 2.49 → 2.33 | 1.13 → 0.97 | 1.42 → 1.28 | +| 100-rectangle connected strip | 401 | 100.9 → 2.32 | 1.32 → 1.20 | 53.6 → 1.41 | +| 100-rectangle strip, rolled back under an extrude | 401 | 203.8 → 2.23 | 1.31 → 1.18 | 54.0 → 1.42 | +| rigid truss of 40 triangles, pinned at one end, dragged at the other | 42 | — → 6.8 | — → 6.3 | — → 0.59 | + +The strip floats (nothing fixed), so its full solves (scripted frames, `drag_end`) ran the +dense rank analysis on 800 variables; with one corner taken as a constant all of it is +eliminated. A live frame's held solve was already cheap: the seed places the strip and LM +returns at once. Of a live strip frame, the seed reads the constraints in about 0.15 ms, so a +per-gesture cache of that reading is not worth its invalidation (its signs depend on the +reference positions, which change every step).