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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75a5609b23
commit
fe4d171e22
47 files changed
+10385
-523
No files matched your search
@@ -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<Option<Vec2>>,
|
||||
pub placed_by: Vec<Option<Placement>>,
|
||||
pub radius: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
#[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<Option<Vec2>> = 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<bool>,
|
||||
/// Points later points may be built on (see [`Placement::anchors`]).
|
||||
anchors: Vec<Option<Vec2>>,
|
||||
/// Anchoring neighbours (by a relation between two points) per point.
|
||||
placed_near: Vec<usize>,
|
||||
queued: Vec<bool>,
|
||||
queue: VecDeque<usize>,
|
||||
}
|
||||
|
||||
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<usize, Vec2> = 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<usize> = by_point.iter().filter(|(i, x)| !self.reading.holds(**i, **x, &view)).map(|(i, _)| *i).collect();
|
||||
let mut cascade: Vec<usize> = 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<bool> = const { Cell::new(true) };
|
||||
static DECOMPOSE: Cell<bool> = const { Cell::new(true) };
|
||||
static DIFFERENTIAL: Cell<bool> = 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<Cell<bool>>, bool);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
self.0.with(|s| s.set(self.1));
|
||||
}
|
||||
}
|
||||
|
||||
fn off<T>(key: &'static LocalKey<Cell<bool>>, 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<T>(f: impl FnOnce() -> T) -> T {
|
||||
off(&SEED, f)
|
||||
}
|
||||
/// Run `f` with the seed's propagation only (no decomposition).
|
||||
pub(crate) fn without_decomposition<T>(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<T>(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
|
||||
}
|
||||
}
|
||||
@@ -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<Check>,
|
||||
}
|
||||
|
||||
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<Case> {
|
||||
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<f64> = 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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case>;
|
||||
|
||||
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<Case> {
|
||||
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<usize> = 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<usize> = 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<Box<dyn Fn(&mut Sketch)>> = 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<usize> = (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:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<usize> = 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<f64> {
|
||||
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<f64> {
|
||||
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<f64>, Vec<f64>)>,
|
||||
}
|
||||
|
||||
/// 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<Vec2>)> {
|
||||
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<Vec2> = 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<f64> = 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<f64> = 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()));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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<Vec2> {
|
||||
let n = sk.points.len();
|
||||
let mut parent: Vec<usize> = (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<usize> = (0..n).map(|i| root(&mut parent, i)).collect();
|
||||
let roots: Vec<usize> = (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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+101
-21
@@ -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<usize>, Vec<usize>) {
|
||||
/// The points and the curves the constraint refers to.
|
||||
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
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<Vec2> {
|
||||
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<usize>, id: Option<&str>) -> Result<usize> {
|
||||
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<usize>; 3], id: Option<&str>) -> Result<usize> {
|
||||
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<bool> {
|
||||
let mut out: Vec<bool> = 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<usize> = (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<Vec<usize>> =
|
||||
@@ -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<usize> {
|
||||
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<bool>, Vec<bool>) {
|
||||
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<usize> = (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"
|
||||
|
||||
@@ -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<Vec<[Vec2; 4]>> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Profile> {
|
||||
let mut comp: Vec<usize> = (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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1227
-195
File diff suppressed because it is too large.
Load diff
@@ -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<T>(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<bool> {
|
||||
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();
|
||||
|
||||
@@ -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<usize> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<Vec3>> {
|
||||
}
|
||||
let mut out: Vec<Vec3> = 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<Vec<Vec3>> {
|
||||
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<usize> {
|
||||
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()))?;
|
||||
|
||||
Reference in new issue
Block a user