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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
75a5609b23
commit
fe4d171e22
47 files changed
+10385
-523
No files matched your search
@@ -2181,6 +2181,28 @@ impl Document {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
|
||||
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
|
||||
/// jump it reaches by stepping the changed values from there, and an edit that would turn
|
||||
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
|
||||
/// from the stored state again after a refused or failed first solve (the geometry is still
|
||||
/// the stored one), from the solved one otherwise.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solvecraft_sketch::DrivingValues,
|
||||
) -> Result<solvecraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
|
||||
+234
-54
@@ -7,7 +7,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solvecraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
|
||||
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||||
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::lineage::Traced;
|
||||
@@ -262,7 +262,8 @@ pub struct ModelState {
|
||||
/// Cosmetic threads (from Thread features and threaded holes).
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
pub bodies: Vec<ModelBody>,
|
||||
pub sketches: Vec<SolvedSketch>,
|
||||
/// Shared between states: a feature that does not change a sketch keeps it as it was.
|
||||
pub sketches: Vec<Arc<SolvedSketch>>,
|
||||
pub body_counter: usize,
|
||||
/// Sheet metal bodies (by body name) and their flat patterns.
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
@@ -295,7 +296,8 @@ impl ModelState {
|
||||
}
|
||||
|
||||
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||||
/// made or changed, and sketches it made, take that key; the rest keep theirs.
|
||||
/// made or changed take that key, sketches it made a digest of their solved geometry (see
|
||||
/// [`quantize`]); the rest keep theirs.
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
@@ -342,8 +344,16 @@ impl ModelState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.sketches.iter().any(|s| s.feature == feature) {
|
||||
self.sketch_keys.insert(feature, key);
|
||||
// Later features read a sketch's solved geometry: they are keyed by it (to the
|
||||
// quantum), so a value set back that solves to the same geometry reuses their results.
|
||||
if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
|
||||
let mut sketch = s.sketch.clone();
|
||||
quantize(&mut sketch);
|
||||
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
|
||||
s.report.ok().hash(&mut h);
|
||||
self.sketch_keys.insert(feature, h.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +404,7 @@ impl ModelState {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
||||
self.sketches.iter().find(|s| s.feature == feature)
|
||||
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +418,11 @@ pub struct FeatureResult {
|
||||
pub boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||
pub ms: f64,
|
||||
pub skipped: bool,
|
||||
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
|
||||
/// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which
|
||||
/// never reuses it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stale: bool,
|
||||
#[serde(skip)]
|
||||
fingerprint: u64,
|
||||
#[serde(skip)]
|
||||
@@ -448,7 +463,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
||||
feature_json(f).hash(&mut h);
|
||||
for n in param_names(f) {
|
||||
n.hash(&mut h);
|
||||
match vals.get(&n) {
|
||||
@@ -463,6 +478,123 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design
|
||||
/// can show, far above the solver's rounding, so a sketch solved again to the same geometry
|
||||
/// reads as the same input.
|
||||
const QUANTUM: f64 = 1e-6;
|
||||
|
||||
/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the
|
||||
/// measured values of driven dimensions.
|
||||
fn quantize(sk: &mut solvecraft_sketch::Sketch) {
|
||||
let q = |v: f64| (v / QUANTUM).round() * QUANTUM;
|
||||
for p in &mut sk.points {
|
||||
p.pos = Vec2::new(q(p.pos.x), q(p.pos.y));
|
||||
}
|
||||
for c in &mut sk.curves {
|
||||
if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind {
|
||||
*r = q(*r);
|
||||
}
|
||||
}
|
||||
for c in sk.constraints.iter_mut().filter(|c| c.driven) {
|
||||
if let Some(v) = c.kind.value() {
|
||||
c.kind.set_value(q(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized.
|
||||
fn feature_json(f: &Feature) -> String {
|
||||
if let FeatureKind::Sketch { .. } = &f.kind {
|
||||
let mut g = f.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
|
||||
quantize(sketch);
|
||||
}
|
||||
return serde_json::to_string(&g).unwrap_or_default();
|
||||
}
|
||||
serde_json::to_string(f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse
|
||||
/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the
|
||||
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
|
||||
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
|
||||
/// match `stored` entity for entity is not adopted.
|
||||
fn adopt_solution(stored: &mut solvecraft_sketch::Sketch, solved: &solvecraft_sketch::Sketch) {
|
||||
if stored.points.len() != solved.points.len()
|
||||
|| stored.curves.len() != solved.curves.len()
|
||||
|| stored.constraints.len() != solved.constraints.len()
|
||||
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
|
||||
p.pos = q.pos;
|
||||
}
|
||||
for (c, d) in stored.curves.iter_mut().zip(&solved.curves) {
|
||||
match (&mut c.kind, &d.kind) {
|
||||
(CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => {
|
||||
*r = *s
|
||||
}
|
||||
(CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => {
|
||||
(*a, *b) = (*sa, *sb);
|
||||
c.reversed = d.reversed;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) {
|
||||
if let Some(v) = d.kind.value() {
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||||
fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
let (mut vals, perr) = doc.param_values();
|
||||
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
|
||||
&& scale != 1.0
|
||||
{
|
||||
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
|
||||
}
|
||||
(vals, perr)
|
||||
}
|
||||
|
||||
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
|
||||
/// the sheet metal or plastic rules it takes defaults from.
|
||||
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool) -> u64 {
|
||||
let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.sheet.rules {
|
||||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
// Plastic features take defaults from the plastic rules.
|
||||
if f.kind.is_plastic() && !doc.plastic.is_empty() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.plastic.rules {
|
||||
for (_, e, _) in r.fields() {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
fp
|
||||
}
|
||||
|
||||
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
|
||||
/// metal and plastic rules (and the parameters they use), components and body placement.
|
||||
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||||
@@ -579,14 +711,79 @@ impl Model {
|
||||
self.results.iter().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
/// Show a sketch's new solution before the model is evaluated again (the frames of an
|
||||
/// interactive drag): `sketch`, already solved with `report`, on the plane it was last
|
||||
/// evaluated on. The sketch's own result and the final state show it, and the sketch and
|
||||
/// every feature after it are marked stale, keeping their last geometry. Nothing else is
|
||||
/// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`,
|
||||
/// changing nothing, when the sketch has no evaluated result or a feature before it is
|
||||
/// already stale (only a full evaluation brings that up to date).
|
||||
pub fn show_live_sketch(&mut self, feature: u64, sketch: solvecraft_sketch::Sketch, report: SolveReport) -> bool {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false };
|
||||
if self.results.iter().take(i).any(|r| r.stale) {
|
||||
return false;
|
||||
}
|
||||
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
|
||||
return false;
|
||||
};
|
||||
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
|
||||
let shown = self.results.iter().rposition(|r| !r.skipped);
|
||||
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
||||
r.stale = true;
|
||||
// The sketch's own state and the final one show it: only that entry changes (the
|
||||
// states are copied on the first frame; later frames own them and swap in place).
|
||||
if (k == i || Some(k) == shown)
|
||||
&& let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature)
|
||||
{
|
||||
*s = solved.clone();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`.
|
||||
pub fn is_stale(&self) -> bool {
|
||||
self.results.iter().any(|r| r.stale)
|
||||
}
|
||||
|
||||
/// Store in `doc` the sketches whose last evaluation solved them for new driving values
|
||||
/// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value
|
||||
/// edit continues from (edits depend on their history, as a user editing dimensions one
|
||||
/// after another expects) and later evaluations repeat no continuation. Their results
|
||||
/// stand for the updated document: the solved sketch is what evaluating it gives.
|
||||
/// Returns how many sketches were stored.
|
||||
pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize {
|
||||
let solved: Vec<SolvedSketch> = self.edited_sketches(doc).cloned().collect();
|
||||
let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 };
|
||||
for s in &solved {
|
||||
if let Ok(stored) = doc.sketch_mut(s.feature) {
|
||||
adopt_solution(stored, &s.sketch);
|
||||
}
|
||||
}
|
||||
// The fingerprints from the first stored sketch on.
|
||||
let vals = evaluation_values(doc).0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
|
||||
for (i, f) in doc.features.iter().enumerate().skip(first) {
|
||||
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker);
|
||||
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
|
||||
r.fingerprint = prev_fp;
|
||||
}
|
||||
}
|
||||
solved.len()
|
||||
}
|
||||
|
||||
/// The sketches the last evaluation solved for driving values other than the ones `doc`
|
||||
/// stores them with (a value edit since they were stored).
|
||||
pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator<Item = &'a SolvedSketch> + 'a {
|
||||
self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| {
|
||||
s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values())
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
let (mut vals, perr) = doc.param_values();
|
||||
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
|
||||
&& scale != 1.0
|
||||
{
|
||||
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
|
||||
}
|
||||
let (vals, perr) = evaluation_values(doc);
|
||||
self.param_errors = perr;
|
||||
let mut prev_fp = 0u64;
|
||||
let mut state = self.empty.clone();
|
||||
@@ -598,40 +795,13 @@ impl Model {
|
||||
let ctx = context_digest(doc, &vals);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.sheet.rules {
|
||||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
// Plastic features take defaults from the plastic rules.
|
||||
if f.kind.is_plastic() && !doc.plastic.is_empty() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.plastic.rules {
|
||||
for (_, e, _) in r.fields() {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
|
||||
prev_fp = fp;
|
||||
if reuse
|
||||
&& let Some(old) = self.results.get(i)
|
||||
&& old.id == f.id
|
||||
&& old.fingerprint == fp
|
||||
&& !old.stale
|
||||
{
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
@@ -649,6 +819,7 @@ impl Model {
|
||||
boolean_diagnostics: Default::default(),
|
||||
ms: 0.0,
|
||||
skipped: true,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -671,6 +842,7 @@ impl Model {
|
||||
boolean_diagnostics: c.boolean_diagnostics,
|
||||
ms: c.ms,
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -775,6 +947,7 @@ impl Model {
|
||||
boolean_diagnostics,
|
||||
ms,
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -2920,15 +3093,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let mut report = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = solve(&mut sk);
|
||||
}
|
||||
if !report.ok() {
|
||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
@@ -2936,7 +3105,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
sk.validate_periodic_splines()?;
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
@@ -3580,6 +3749,12 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
}
|
||||
}
|
||||
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||||
// Only a sketch the frame moves is copied.
|
||||
let maps_wire = |w: &solvecraft_sketch::Wire| !crate::is_identity(if w.space == solvecraft_sketch::WireSpace::Component { m } else { &inv });
|
||||
if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) {
|
||||
continue;
|
||||
}
|
||||
let s = Arc::make_mut(s);
|
||||
if !crate::is_identity(m)
|
||||
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||||
{
|
||||
@@ -3646,7 +3821,12 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
}
|
||||
if primary {
|
||||
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
|
||||
let mut s2 = s.clone();
|
||||
if ident {
|
||||
// In place: shared as it is.
|
||||
out.sketches.push(s.clone());
|
||||
continue;
|
||||
}
|
||||
let mut s2 = SolvedSketch::clone(s);
|
||||
if !ident {
|
||||
let p = &s.plane;
|
||||
if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) {
|
||||
@@ -3658,7 +3838,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
map_wire(wire, &m);
|
||||
}
|
||||
}
|
||||
out.sketches.push(s2);
|
||||
out.sketches.push(Arc::new(s2));
|
||||
}
|
||||
for t in st
|
||||
.threads
|
||||
|
||||
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
@@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
|
||||
@@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
|
||||
@@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
|
||||
@@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
|
||||
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(s, &p)?;
|
||||
let r = spec.run_nested(s, &p)?;
|
||||
s.refresh();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
@@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, prev);
|
||||
let label = now.label.clone();
|
||||
s.redo.push(now);
|
||||
Ok(json!({"undone": label}))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, next);
|
||||
let label = now.label.clone();
|
||||
s.undo.push(now);
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
/// Put the session at `snap`; returns where it was, under the same label.
|
||||
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
|
||||
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
|
||||
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
|
||||
s.doc = snap.doc;
|
||||
s.active_sketch = snap.active_sketch;
|
||||
s.pending_moves = snap.pending_moves;
|
||||
s.selection.clear();
|
||||
// A new document is evaluated by `execute`.
|
||||
if same {
|
||||
s.refresh();
|
||||
}
|
||||
was
|
||||
}
|
||||
|
||||
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||
}
|
||||
@@ -477,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let r = spec.run_nested(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ use serde_json::Value;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
/// A command's form for one frame of an interactive gesture: its result and the sketch it
|
||||
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
|
||||
/// evaluate as usual).
|
||||
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
|
||||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
||||
pub struct CommandSpec {
|
||||
@@ -74,11 +78,40 @@ pub struct CommandSpec {
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub undoable: bool,
|
||||
/// What runs instead of `run` when the command is given `live: true` (a frame of an
|
||||
/// interactive gesture, see [`Session::execute`]).
|
||||
pub live: Option<LiveRun>,
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
|
||||
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
|
||||
CommandSpec {
|
||||
id,
|
||||
label,
|
||||
tab: "",
|
||||
panel: "",
|
||||
icon: "",
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: always,
|
||||
run,
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
}
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
self
|
||||
}
|
||||
/// Reads only the document (and history), never the evaluated model.
|
||||
pub const fn doc_only(mut self) -> Self {
|
||||
self.reads_model = false;
|
||||
self
|
||||
}
|
||||
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
|
||||
self.tab = tab;
|
||||
@@ -105,6 +138,12 @@ impl CommandSpec {
|
||||
self.undoable = false;
|
||||
self
|
||||
}
|
||||
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
|
||||
/// undo step and the evaluation).
|
||||
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
|
||||
(self.run)(s, p)
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("joint")
|
||||
.params("occurrences: [ids or names] (two or more); name?"),
|
||||
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
|
||||
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
|
||||
|
||||
@@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
@@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
@@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool {
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.change";
|
||||
let solved = s.solved_sketches();
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
@@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
@@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.add";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
@@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = param_name("parameters.delete", p)?.to_string();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().remove_param(&name)?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
@@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `text` or `path`")),
|
||||
};
|
||||
let solved = s.solved_sketches();
|
||||
let names = s.doc_mut().import_params(&text)?;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,11 @@ fn width_follows_an_expression() {
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||
/// for "nothing changed". timeline.edit reports the same.
|
||||
/// Setting a value back reuses the earlier results and says so: the features it restored are
|
||||
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
|
||||
/// sketch dimension set back solves the sketch again from its current state (value edits
|
||||
/// continue from the last solved state); the features after it see the same geometry and are
|
||||
/// restored.
|
||||
#[test]
|
||||
fn setting_a_parameter_back_reports_restored_features() {
|
||||
let mut s = Session::default();
|
||||
@@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() {
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
@@ -243,3 +245,302 @@ fn csv_and_json_round_trip() {
|
||||
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
|
||||
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
|
||||
/// Returns the session, the sketch feature and the nine dimensions' parameters.
|
||||
fn linkage_session() -> (Session, u64, Vec<String>) {
|
||||
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
|
||||
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
|
||||
let ids = ["a", "b", "c", "d", "e", "f"];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
|
||||
for (id, at) in ids.iter().zip(layout) {
|
||||
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
|
||||
let params = pairs
|
||||
.iter()
|
||||
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
|
||||
.collect();
|
||||
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
|
||||
(s, feature, params)
|
||||
}
|
||||
|
||||
/// Edited values the iteration cannot reach in one jump from the layout.
|
||||
const REACHED_BY_CONTINUATION: [f64; 9] = [
|
||||
49.75798473660107,
|
||||
44.249179252138724,
|
||||
28.66332553713371,
|
||||
49.725624597008775,
|
||||
45.81086166277159,
|
||||
35.887660702567544,
|
||||
45.64700450204954,
|
||||
50.756441510188495,
|
||||
63.72063313963373,
|
||||
];
|
||||
|
||||
/// Turn of both triangles in the evaluated sketch.
|
||||
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
|
||||
let state = s.model.state();
|
||||
let sk = &state.sketch(feature).unwrap().sketch;
|
||||
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
|
||||
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
|
||||
}
|
||||
|
||||
/// Set the parameters in the document only (as an edit that does not store the sketch).
|
||||
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
|
||||
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
|
||||
s.doc_mut().change_params_atomic(&changes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
|
||||
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
|
||||
// and stores what it reached: evaluating the document afresh needs no continuation.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
// All nine at once: one at a time, some intermediate values have no solution.
|
||||
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
let mut fresh = solvecraft_doc::Model::default();
|
||||
fresh.evaluate(&s.doc);
|
||||
let again = fresh.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(again.ok() && again.continuations == 0, "{again:?}");
|
||||
// The kept results are what evaluating the stored document gives.
|
||||
let (kept, afresh) = (s.model.state(), fresh.state());
|
||||
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
|
||||
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
|
||||
// In one go (one evaluation from the layout) it needs continuation.
|
||||
let (mut t, feature, params) = linkage_session();
|
||||
run(&mut t, "sketch.finish", json!({}));
|
||||
set_params(&mut t, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
t.refresh();
|
||||
let once = t.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(once.ok() && once.continuations > 0, "{once:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
|
||||
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
|
||||
// stored dimensions keep the values its geometry is solved for.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Values the linkage reaches only with its top triangle mirrored.
|
||||
const FLIPPING: [f64; 9] = [
|
||||
26.483003374917082,
|
||||
90.0253808040292,
|
||||
84.67215428223084,
|
||||
54.580784880976275,
|
||||
68.12871394439186,
|
||||
30.98508537669166,
|
||||
116.94895853350752,
|
||||
103.75535113133466,
|
||||
32.83103173640168,
|
||||
];
|
||||
|
||||
/// All nine parameters set by one command.
|
||||
fn import_values(params: &[String], values: &[f64]) -> Value {
|
||||
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
json!({"text": serde_json::to_string(&batch).unwrap()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
|
||||
let why = err(&mut s, "parameters.import", import_values(¶ms, &FLIPPING));
|
||||
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_stored_with_other_values_still_rebuilds() {
|
||||
// A design whose stored sketch is not solved for its parameters (saved before value edits
|
||||
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
|
||||
// warning, the flipped constraints listed as conflicts, and the solid built from it.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
|
||||
run(&mut s, "sketch.line", json!({"points": line}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
|
||||
set_params(&mut s, ¶ms, &FLIPPING);
|
||||
let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let state = s.model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
|
||||
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
// Listed as conflicts wherever conflicts are shown.
|
||||
let mut s = s;
|
||||
s.active_sketch = Some(feature);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
|
||||
for id in &solved.report.folded {
|
||||
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
|
||||
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
|
||||
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
|
||||
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
|
||||
// sketch: it applies, and the projecting sketch carries a warning.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
|
||||
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().sketch(id).unwrap().report.ok());
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
|
||||
assert!(!s.model.state().sketch(id).unwrap().report.ok());
|
||||
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
|
||||
// Auto-constrain works on the current values: reached by continuation, its result is
|
||||
// stored solved for them; values no geometry satisfies refuse it and change nothing.
|
||||
let consistent = |s: &Session, feature: u64| {
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
(measured - c.kind.value().unwrap()).abs() < 1e-6
|
||||
})
|
||||
};
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
let before = s.doc.sketch(feature).unwrap().clone();
|
||||
let why = err(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(why.contains("conflict"), "{why}");
|
||||
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
|
||||
assert!(consistent(&s, feature));
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(consistent(&s, feature));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
|
||||
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored sketch's points, by id.
|
||||
fn stored_points(s: &Session, feature: u64) -> Vec<solvecraft_geom::Vec2> {
|
||||
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (expr, points) = (s.doc.param(¶ms[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
|
||||
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
|
||||
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
|
||||
assert_eq!(s.doc.param(¶ms[7]).map(|p| p.expr.clone()), expr);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.ok());
|
||||
// A batch with one such value: none of it.
|
||||
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
|
||||
let batch: Vec<_> = params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
|
||||
.collect();
|
||||
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
assert!(why.contains("Linkage"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_edits_continue_from_the_last_solved_state() {
|
||||
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
|
||||
// and setting both values back reproduces A (the linkage is fully constrained).
|
||||
let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let a = stored_points(&s, feature);
|
||||
let (p7, p8) = (s.doc.param(¶ms[7]).unwrap().expr.clone(), s.doc.param(¶ms[8]).unwrap().expr.clone());
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
|
||||
let b_sketch = s.doc.sketch(feature).unwrap().clone();
|
||||
let b = stored_points(&s, feature);
|
||||
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
|
||||
let c = stored_points(&s, feature);
|
||||
// The same edit solved from B directly.
|
||||
let mut from_b = b_sketch.clone();
|
||||
let stored = from_b.driving_values();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
|
||||
assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
|
||||
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
|
||||
assert!(close(&stored_points(&s, feature), &a, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_configurations_back_reuses_the_later_features() {
|
||||
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
|
||||
// geometry, and the extrude after it is restored from the cache.
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "config.configure", json!({"params": ["len"]}));
|
||||
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "B"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
|
||||
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
|
||||
let before = hits(&s);
|
||||
let r = run(&mut s, "config.activate", json!({"row": "A"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
assert!(hits(&s) > before, "{r}");
|
||||
}
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
|
||||
+212
-112
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
@@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
#[path = "sketch_drag.rs"]
|
||||
mod drag;
|
||||
#[path = "sketch_face.rs"]
|
||||
pub(super) mod face;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
use drag::drag_entity;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
@@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
|
||||
),
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
|
||||
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
|
||||
),
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
|
||||
.enabled(in_sketch)
|
||||
@@ -219,25 +225,143 @@ pub(super) fn edit<T>(
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<T>,
|
||||
) -> Result<(T, Value)> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
|
||||
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
/// A solve's outcome as commands return it.
|
||||
pub(super) fn report_json(rep: &SolveReport) -> Value {
|
||||
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
|
||||
}
|
||||
|
||||
/// What an [`edit_solved`] step leaves for the edit's own solve.
|
||||
pub(super) enum Left {
|
||||
/// The sketch, solved from where it is.
|
||||
Unsolved,
|
||||
/// The sketch, solved keeping the orientation of these positions (the last solved ones,
|
||||
/// before a drag left a step placed but unsolved).
|
||||
MovedFrom(Vec<Vec2>),
|
||||
/// The sketch, solved with the dimensions' values (from the parameters) applied.
|
||||
Solved,
|
||||
}
|
||||
|
||||
impl Left {
|
||||
/// Solved again by the edit even when already solved (for its report).
|
||||
pub(super) fn solve_again(self) -> Left {
|
||||
match self {
|
||||
Left::Solved => Left::Unsolved,
|
||||
left => left,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns
|
||||
/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the
|
||||
/// report is `None`.
|
||||
pub(super) fn edit_solved<T>(
|
||||
s: &mut Session,
|
||||
id: u64,
|
||||
strict: bool,
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>,
|
||||
) -> Result<(T, Option<SolveReport>)> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
super::sketch_project::refresh(s, &doc, id, &mut sk);
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let mut rep = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
sk.refresh_offsets()?;
|
||||
rep = solve(&mut sk);
|
||||
}
|
||||
if strict && !rep.ok() {
|
||||
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
|
||||
}
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let (out, left) = f(&mut sk, &mut doc)?;
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
let rep = match left {
|
||||
// A drag step solved it (held, with the dimensions' values applied).
|
||||
Left::Solved if !offsets => None,
|
||||
// A drag left a step placed but unsolved: solve it keeping the orientation of the last
|
||||
// solved positions. A drag changes no values, so there is nothing to continue.
|
||||
Left::MovedFrom(reference) => {
|
||||
doc.apply_dimension_values(&doc.param_values().0, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
if offsets {
|
||||
sk.refresh_offsets()?;
|
||||
rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
}
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
// Solved for the document's values, continuing from the stored solved state.
|
||||
Left::Solved | Left::Unsolved => {
|
||||
let (vals, _) = doc.param_values();
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
#[cfg(test)]
|
||||
solve_counts::record(Analysis::Full);
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
};
|
||||
sk.validate_periodic_splines()?;
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
Ok((out, rep))
|
||||
}
|
||||
|
||||
/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an
|
||||
/// error; otherwise such a sketch is stored as the edit left it before the solve, its values
|
||||
/// and geometry together, so the stored sketch stays solved for its stored values.
|
||||
fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result<SolveReport> {
|
||||
if strict && !rep.keeps_orientation() {
|
||||
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
|
||||
}
|
||||
if !rep.keeps_orientation() {
|
||||
*sk = unsolved;
|
||||
}
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
|
||||
pub(super) fn counted_solve(
|
||||
sk: &mut Sketch,
|
||||
hold: &Hold,
|
||||
reference: Option<&[Vec2]>,
|
||||
analysis: Analysis,
|
||||
budget: Option<&mut Budget>,
|
||||
) -> SolveReport {
|
||||
#[cfg(test)]
|
||||
solve_counts::record(analysis);
|
||||
solve_with(sk, hold, reference, analysis, budget)
|
||||
}
|
||||
|
||||
/// Inside an `edit` closure: solve the sketch for the document's parameter values from its
|
||||
/// stored solved state, as the edit's own solve does, for commands that work on the current
|
||||
/// geometry.
|
||||
pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result<SolveReport> {
|
||||
let stored = sk.driving_values();
|
||||
let (values, _) = doc.param_values();
|
||||
Ok(doc.solve_sketch(&values, sk, &stored)?)
|
||||
}
|
||||
|
||||
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
|
||||
/// with a rank analysis).
|
||||
#[cfg(test)]
|
||||
pub(super) mod solve_counts {
|
||||
use std::cell::Cell;
|
||||
|
||||
use solvecraft_sketch::Analysis;
|
||||
|
||||
thread_local! {
|
||||
static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
pub fn record(analysis: Analysis) {
|
||||
COUNTS.with(|c| {
|
||||
let (n, full) = c.get();
|
||||
c.set((n + 1, full + usize::from(analysis == Analysis::Full)));
|
||||
});
|
||||
}
|
||||
|
||||
/// The counts since the last call.
|
||||
pub fn take() -> (usize, usize) {
|
||||
COUNTS.with(|c| c.replace((0, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
@@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
|
||||
driven: false,
|
||||
text: None,
|
||||
}));
|
||||
let ok = solve_holding(&mut trial, &h).ok();
|
||||
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
|
||||
trial.constraints.retain(|c| c.id != TEMP);
|
||||
if ok && !collapsed(sk, &trial) {
|
||||
*sk = trial;
|
||||
@@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
{
|
||||
let rep = solve(&mut sk.clone());
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
|
||||
}
|
||||
constrain(s, p, cmd, |sk| {
|
||||
if let Some((a, b)) = line_points {
|
||||
@@ -1823,109 +1947,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move_point";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (_, rep) = edit_solved(s, id, false, |sk, _| {
|
||||
let q = point_ref(sk, p.get("point"), cmd, "point")?;
|
||||
drag_entity(sk, Ent::Point(q), to, to);
|
||||
Ok(())
|
||||
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = p.get("from").and_then(vec2).unwrap_or(to);
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
drag_entity(sk, e, from, to);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
drag_frame(s, p, false).map(|(v, _)| v)
|
||||
}
|
||||
|
||||
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
|
||||
/// fully constrained geometry stays (the solve that follows pulls it back).
|
||||
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
let d = to - from;
|
||||
let mut hold = Hold::default();
|
||||
match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return;
|
||||
}
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = to;
|
||||
}
|
||||
hold.points.push(q);
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
|
||||
let locked = sk.locked_points();
|
||||
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
|
||||
if !locked.get(q).copied().unwrap_or(true)
|
||||
&& let Some(pt) = sk.points.get_mut(q)
|
||||
{
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, .. } => {
|
||||
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
|
||||
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
&& r > 1e-9
|
||||
{
|
||||
*slot = r;
|
||||
}
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let Some(o) = sk.point(cc) else { return };
|
||||
let r = o.dist(to);
|
||||
if r > 1e-9 {
|
||||
for q in [a, b] {
|
||||
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
|
||||
set(sk, q, o + u * r);
|
||||
}
|
||||
}
|
||||
}
|
||||
hold.points.extend([cc, a, b]);
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, .. } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
if minor > 1e-9
|
||||
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
{
|
||||
*slot = minor;
|
||||
}
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
}
|
||||
hold.points.extend(k.point_ids());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
drag_frame(s, p, true)
|
||||
}
|
||||
|
||||
/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show.
|
||||
fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => to,
|
||||
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
|
||||
};
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
|
||||
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
|
||||
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
// The held solves work on the dimensions' current values (a solved step applies them).
|
||||
doc.apply_dimension_values(&doc.param_values().0, sk)?;
|
||||
let (d, left) = drag_entity(sk, e, from, to, live);
|
||||
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
|
||||
// its degrees of freedom.
|
||||
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
|
||||
})?;
|
||||
// Where the grabbed spot went: `from` moved as the dragged points did on average (with the
|
||||
// whole step when it followed; otherwise as the sketch's own solve left them).
|
||||
let sk = s.doc.sketch(id)?;
|
||||
let moves: Vec<Vec2> = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect();
|
||||
let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64);
|
||||
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
|
||||
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]});
|
||||
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model.
|
||||
/// It reads the model only before the sketch it edits (never stale), so `execute` does not
|
||||
/// evaluate first.
|
||||
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag_end";
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let start = s.doc.clone();
|
||||
let mut constraint = Value::Null;
|
||||
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
|
||||
let name = c.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
let spec = super::find_command(name)
|
||||
.filter(|spec| spec.id.starts_with("sketch.constraint."))
|
||||
.ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?;
|
||||
let params = c.get("params").cloned().unwrap_or(Value::Null);
|
||||
// A constraint that cannot be added (it would over-constrain the sketch) is not kept.
|
||||
constraint = spec.run_nested(s, ¶ms).unwrap_or_else(|e| {
|
||||
s.doc = start.clone();
|
||||
json!({"error": e.to_string()})
|
||||
});
|
||||
}
|
||||
let mut trial = sk.clone();
|
||||
if solve_holding(&mut trial, &hold).ok() {
|
||||
*sk = trial;
|
||||
// A new constraint changed the document: `execute` evaluates the model after this.
|
||||
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
|
||||
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
|
||||
}
|
||||
if s.model.is_stale() {
|
||||
s.refresh();
|
||||
}
|
||||
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
|
||||
Ok(json!({"constraint": constraint, "sketch": report}))
|
||||
}
|
||||
|
||||
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
|
||||
@@ -2000,6 +2097,9 @@ mod rect_tests;
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_drag_live_tests.rs"]
|
||||
mod drag_live_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
@@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?,
|
||||
None => 0,
|
||||
};
|
||||
// Values of existing dimensions must be applied before judging degrees of freedom.
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, sk)?;
|
||||
// Values of existing dimensions must be applied (and reached from the stored solved
|
||||
// state) before judging degrees of freedom; a sketch they would turn over keeps its
|
||||
// stored geometry, as the edit's own solve then refuses them.
|
||||
let rep = super::sketch::solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})"))));
|
||||
}
|
||||
Ok(run_auto(sk, doc, p, datum))
|
||||
})?;
|
||||
Ok(json!({"added": added, "dof": dof, "sketch": info}))
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Dragging sketch geometry without jumping to another solution.
|
||||
//!
|
||||
//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's
|
||||
//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded
|
||||
//! on the positions from before it: the points the solver's construction places (from distances
|
||||
//! and alignments to placed points) keep the orientation they had there however far the step.
|
||||
//! The rest is left to the iteration, which from far away can land on any solution, so a long
|
||||
//! drag still walks from where the geometry is to where the cursor puts it in short steps, each
|
||||
//! solved from the previous step's result (continuation). Continuation is what keeps lengths the
|
||||
//! construction does not read (an Equal to another line, a rigid shape hanging off a held point);
|
||||
//! it also lets the arc sweep guard see every step, and lets a path that crosses where the
|
||||
//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only
|
||||
//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
|
||||
|
||||
use super::{Ent, Left, counted_solve};
|
||||
|
||||
/// Longest step, as a fraction of the smallest driving length or radius of the dragged group.
|
||||
/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch
|
||||
/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across
|
||||
/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start):
|
||||
/// this keeps a factor of four. It holds where the seed placed what it could: once a held
|
||||
/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag
|
||||
/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch.
|
||||
const STEP_FRACTION: f64 = 0.25;
|
||||
/// The steps of a drag whose seed did not place everything it could (as before seeding steps).
|
||||
const SHORT_STEP_FRACTION: f64 = 0.125;
|
||||
/// Shortest step (mm), so a tiny feature cannot make a drag crawl.
|
||||
const MIN_STEP: f64 = 1e-3;
|
||||
/// Most steps one drag is split into.
|
||||
const MAX_STEPS: usize = 64;
|
||||
/// After a step fails, the way to it from the last solved state is halved until what is left is
|
||||
/// shorter than a step over this; at most `MAX_STEPS` such solves per drag.
|
||||
const HALVINGS: usize = 16;
|
||||
/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start.
|
||||
const SWEEP_EPS: f64 = 1e-6;
|
||||
|
||||
/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`).
|
||||
enum Path {
|
||||
/// Points slide by `d` (a dragged point by its way to the cursor).
|
||||
Slide { pts: Vec<(usize, Vec2)>, d: Vec2 },
|
||||
/// A circle's radius or an ellipse's minor radius follows the cursor.
|
||||
Radius { c: usize, from: f64, to: f64 },
|
||||
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
|
||||
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn length(&self) -> f64 {
|
||||
match self {
|
||||
Path::Slide { d, .. } => d.len(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points the drag moves.
|
||||
fn points(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
let pts = match self {
|
||||
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
|
||||
Path::Radius { .. } => &[],
|
||||
};
|
||||
pts.iter().map(|(q, _)| *q)
|
||||
}
|
||||
|
||||
/// Put the dragged geometry where it is at `t` along the drag.
|
||||
fn apply(&self, sk: &mut Sketch, t: f64) {
|
||||
let mut set = |q: usize, at: Vec2| {
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Path::Slide { pts, d } => {
|
||||
for &(q, at) in pts {
|
||||
set(q, at + *d * t);
|
||||
}
|
||||
}
|
||||
Path::ArcRadius { o, ends, from, to } => {
|
||||
let r = from + (to - from) * t;
|
||||
for &(q, u) in ends {
|
||||
set(q, *o + u * r);
|
||||
}
|
||||
}
|
||||
Path::Radius { c, from, to } => {
|
||||
if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
|
||||
*r = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
|
||||
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
|
||||
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
|
||||
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
|
||||
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
|
||||
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
|
||||
///
|
||||
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
|
||||
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
|
||||
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
|
||||
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
|
||||
};
|
||||
#[cfg(test)]
|
||||
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
|
||||
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let mut frame = live.then(frame_budget);
|
||||
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
|
||||
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
|
||||
// constraints pin down keeps the orientation it had there. A failed or wrapping step is
|
||||
// undone (a failed solve leaves the step placed). Also returns whether the decomposition ran
|
||||
// out of budget.
|
||||
let mut solves = Solves::default();
|
||||
let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| {
|
||||
path.apply(sk, t);
|
||||
let mut own = Budget::full();
|
||||
let budget = frame.as_mut().unwrap_or(&mut own);
|
||||
let spent = budget.spent();
|
||||
let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok();
|
||||
solves.count += 1;
|
||||
solves.work += budget.spent() - spent;
|
||||
solves.cut |= budget.exhausted();
|
||||
let outcome = if !ok {
|
||||
Step::Failed
|
||||
} else if last.sweep_wrapped(sk) {
|
||||
Step::Wrapped
|
||||
} else {
|
||||
return Step::Solved;
|
||||
};
|
||||
last.restore(sk);
|
||||
outcome
|
||||
};
|
||||
let mut last = Geometry::default();
|
||||
last.take(sk);
|
||||
// How far along the last solved state is; the first step since it that failed.
|
||||
let (mut good, mut failed) = (0.0, None);
|
||||
let mut spare = MAX_STEPS;
|
||||
// The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in
|
||||
// short steps.
|
||||
let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0);
|
||||
let mut shortened = false;
|
||||
'walk: while t < 1.0 {
|
||||
if solves.cut && !shortened {
|
||||
shortened = true;
|
||||
let rest = 1.0 - t;
|
||||
let m = steps(rest * short as f64, 1.0);
|
||||
(base, dt, k) = (t, rest / m as f64, 0);
|
||||
}
|
||||
k += 1;
|
||||
t = base + dt * k as f64;
|
||||
// The last step ends exactly at the cursor.
|
||||
if 1.0 - t < 0.5 * dt {
|
||||
t = 1.0;
|
||||
}
|
||||
match step(sk, &last, t, &mut solves) {
|
||||
Step::Solved => {
|
||||
(good, failed) = (t, None);
|
||||
if t < 1.0 {
|
||||
last.take(sk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Step::Wrapped => {
|
||||
failed = None;
|
||||
break 'walk;
|
||||
}
|
||||
Step::Failed => {}
|
||||
}
|
||||
if failed.is_none() {
|
||||
// Halve the way to the failed step, so the state the next steps start from (and are
|
||||
// seeded on) is as close to it as the geometry follows. The shortest move is tried
|
||||
// first: geometry that cannot move at all (pinned) costs one solve, not every halving.
|
||||
let fine = dt / HALVINGS as f64;
|
||||
let mut miss = t;
|
||||
let mut first = true;
|
||||
while spare > 0 && miss - good > fine {
|
||||
spare -= 1;
|
||||
let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) };
|
||||
match step(sk, &last, mid, &mut solves) {
|
||||
Step::Solved => {
|
||||
good = mid;
|
||||
last.take(sk);
|
||||
}
|
||||
Step::Wrapped => break 'walk,
|
||||
Step::Failed => miss = mid,
|
||||
}
|
||||
}
|
||||
failed = Some(t);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
last_drag::set(solves);
|
||||
let left = match failed {
|
||||
// The failed step is left placed for the edit's solve, seeded on the last solved state.
|
||||
Some(t) => {
|
||||
path.apply(sk, t);
|
||||
Left::MovedFrom(last.pos)
|
||||
}
|
||||
None if good > 0.0 => Left::Solved,
|
||||
None => Left::Unsolved,
|
||||
};
|
||||
(Dragged { reached: good, moving }, left)
|
||||
}
|
||||
|
||||
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
|
||||
/// out of budget.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Solves {
|
||||
pub count: usize,
|
||||
pub work: usize,
|
||||
pub cut: bool,
|
||||
}
|
||||
|
||||
/// One live frame's decomposition budget (smaller in tests that set one).
|
||||
fn frame_budget() -> Budget {
|
||||
#[cfg(test)]
|
||||
if let Some(work) = last_drag::frame_work() {
|
||||
return Budget::new(work);
|
||||
}
|
||||
Budget::frame()
|
||||
}
|
||||
|
||||
/// What the last drag on this thread did, and a frame budget for tests to set.
|
||||
#[cfg(test)]
|
||||
pub(super) mod last_drag {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::Solves;
|
||||
|
||||
thread_local! {
|
||||
static LAST: Cell<Solves> = const { Cell::new(Solves { count: 0, work: 0, cut: false }) };
|
||||
static FRAME: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set(s: Solves) {
|
||||
LAST.with(|c| c.set(s));
|
||||
}
|
||||
|
||||
/// The held solves of the last drag.
|
||||
pub fn get() -> Solves {
|
||||
LAST.with(Cell::get)
|
||||
}
|
||||
|
||||
pub fn frame_work() -> Option<usize> {
|
||||
FRAME.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the frame budget when dropped, also when the test panics.
|
||||
struct Restore(Option<usize>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
FRAME.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with live frames given `work` units of decomposition budget.
|
||||
pub fn with_frame_work<R>(work: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(FRAME.with(|c| c.replace(Some(work))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// How one held solve of a drag went.
|
||||
enum Step {
|
||||
Solved,
|
||||
Failed,
|
||||
/// Solved, but it swept an arc through its own start.
|
||||
Wrapped,
|
||||
}
|
||||
|
||||
/// How far a drag got.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Dragged {
|
||||
/// The part of the way from where it was grabbed to the cursor the geometry followed (0..1).
|
||||
pub reached: f64,
|
||||
/// The points the drag moves (not the fixed ones it holds), where they were before it.
|
||||
pub moving: Vec<(usize, Vec2)>,
|
||||
}
|
||||
|
||||
/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve
|
||||
/// may swap), kept to undo a step. Its buffers are reused from step to step.
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
pos: Vec<Vec2>,
|
||||
curves: Vec<(CurveKind, bool)>,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn take(&mut self, sk: &Sketch) {
|
||||
self.pos.clear();
|
||||
self.pos.extend(sk.points.iter().map(|p| p.pos));
|
||||
self.curves.clear();
|
||||
self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed)));
|
||||
}
|
||||
|
||||
fn restore(&self, sk: &mut Sketch) {
|
||||
for (p, at) in sk.points.iter_mut().zip(&self.pos) {
|
||||
p.pos = *at;
|
||||
}
|
||||
for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) {
|
||||
c.kind = kind.clone();
|
||||
c.reversed = *reversed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full
|
||||
/// turn, or ran from clear of them onto one? An arc already that close may still move.
|
||||
/// Measured on the arc's ends as they were before the step (a solve may swap them).
|
||||
fn sweep_wrapped(&self, sk: &Sketch) -> bool {
|
||||
let was = |q: usize| self.pos.get(q).copied();
|
||||
let now = |q: usize| sk.point(q);
|
||||
let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s);
|
||||
self.curves.iter().any(|(kind, _)| {
|
||||
let CurveKind::Arc { c, a, b } = *kind else { return false };
|
||||
let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false };
|
||||
(s1 - s0).abs() > PI || (clear(s0) && !clear(s1))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan
|
||||
/// `drag_entity` runs, for tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize {
|
||||
prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n)
|
||||
}
|
||||
|
||||
/// A drag's path, what its solves hold and its number of steps (and of short steps, see
|
||||
/// `SHORT_STEP_FRACTION`), or `None` if it cannot move.
|
||||
fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> {
|
||||
if !(from.is_finite() && to.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (path, hold) = plan(sk, e, from, to)?;
|
||||
let n = step_count(sk, &hold, path.length(), STEP_FRACTION);
|
||||
let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION);
|
||||
Some((path, hold, n, short))
|
||||
}
|
||||
|
||||
/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer
|
||||
/// steps keep (the planned count, `drag_steps`, is not capped).
|
||||
#[cfg(test)]
|
||||
pub(super) mod step_cap {
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static CAP: Cell<usize> = const { Cell::new(super::MAX_STEPS) };
|
||||
}
|
||||
|
||||
pub fn get() -> usize {
|
||||
CAP.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the cap when dropped, also when the test panics.
|
||||
struct Restore(usize);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
CAP.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with drags split into at most `cap` steps (at least one).
|
||||
pub fn with<R>(cap: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(CAP.with(|c| c.replace(cap.max(1))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`.
|
||||
fn steps(length: f64, step: f64) -> usize {
|
||||
let n = (length / step).ceil();
|
||||
if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 }
|
||||
}
|
||||
|
||||
/// The dragged geometry's path and what the solves hold, or `None` if it cannot move.
|
||||
fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
let mut hold = Hold::default();
|
||||
let path = match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return None;
|
||||
}
|
||||
hold.points.push(q);
|
||||
let at = sk.point(q)?;
|
||||
Path::Slide { pts: vec![(q, at)], d: to - at }
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return None;
|
||||
}
|
||||
let kind = sk.curves.get(c).map(|x| x.kind.clone())?;
|
||||
let locked = sk.locked_points();
|
||||
let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true);
|
||||
// A cursor on the centre leaves the radius as it is.
|
||||
let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r);
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, r } => {
|
||||
let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r));
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: to_r }
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let o = sk.point(cc)?;
|
||||
let r = sk.radius(c).unwrap_or(0.0);
|
||||
hold.points.extend([cc, a, b]);
|
||||
let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect();
|
||||
Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) }
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (o, pm) = (sk.point(cc)?, sk.point(m)?);
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
let mut ids = k.point_ids();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
|
||||
hold.points.extend(ids);
|
||||
Path::Slide { pts, d: to - from }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((path, hold))
|
||||
}
|
||||
|
||||
/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest
|
||||
/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most
|
||||
/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one
|
||||
/// step, found without grouping the sketch.
|
||||
fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize {
|
||||
let step = |size: f64| (size * fraction).max(MIN_STEP);
|
||||
if length <= step(smallest_size(sk, hold, None)) {
|
||||
return 1;
|
||||
}
|
||||
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
|
||||
steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii }))))
|
||||
}
|
||||
|
||||
/// What a solve moves together: per point and per curve radius.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Group<'a> {
|
||||
points: &'a [bool],
|
||||
radii: &'a [bool],
|
||||
}
|
||||
|
||||
impl Group<'_> {
|
||||
/// Does an entity with these points and curves belong to the group?
|
||||
fn has(&self, points: &[usize], curves: &[usize]) -> bool {
|
||||
let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false));
|
||||
any(self.points, points) || any(self.radii, curves)
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest size that can make a constrained shape take another solution (of the geometry
|
||||
/// in `group`, or of the whole sketch), or infinity when there is none: every length the
|
||||
/// constraints hold. That is driving length dimensions (offsets along an axis or from a line
|
||||
/// too: the solver holds their size, either side), radii and the current lengths of lines held
|
||||
/// equal to another (a link's length can come from an Equal as well as a dimension). A line that
|
||||
/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn
|
||||
/// over, so it does not shrink the steps. Nor does a distance between two held points (the
|
||||
/// dragged line's own length): held geometry moves as one. A circle's radius counts when the
|
||||
/// solve moves the radius itself (not merely its centre).
|
||||
fn smallest_size(sk: &Sketch, hold: &Hold, group: Option<Group>) -> f64 {
|
||||
// Point lists are built only to test a size smaller than the smallest so far against the
|
||||
// group; without a group nothing is built.
|
||||
let in_group = |points: &dyn Fn() -> Vec<usize>, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves));
|
||||
// A radius is the circle's own variable, or (an arc's) the distance between its points.
|
||||
let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) {
|
||||
Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]),
|
||||
Some(k) => in_group(&|| k.point_ids(), &[]),
|
||||
None => false,
|
||||
};
|
||||
let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b));
|
||||
let points = |k: &ConstraintKind| sk.constraint_points(k);
|
||||
let mut min = f64::INFINITY;
|
||||
let mut take = |size: f64, counts: &dyn Fn() -> bool| {
|
||||
let size = size.abs();
|
||||
if size.is_finite() && size > 1e-9 && size < min && counts() {
|
||||
min = size;
|
||||
}
|
||||
};
|
||||
for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) {
|
||||
match *k {
|
||||
ConstraintKind::Equal { a, b } => {
|
||||
if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) {
|
||||
take(v, &|| in_group(&|| points(k), &[]));
|
||||
}
|
||||
}
|
||||
ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)),
|
||||
ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)),
|
||||
_ => {
|
||||
if let Some(v) = driving_length(k) {
|
||||
// Held points move as one: a length between them never changes.
|
||||
take(v, &|| {
|
||||
let pts = points(k);
|
||||
!pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..sk.curves.len() {
|
||||
if let Some(r) = sk.radius(c) {
|
||||
take(r, &|| radius_in_group(c));
|
||||
}
|
||||
}
|
||||
min
|
||||
}
|
||||
|
||||
/// The length a driving dimension sets (a linear diameter's radius), if it sets one.
|
||||
fn driving_length(k: &ConstraintKind) -> Option<f64> {
|
||||
if k.is_angle() {
|
||||
return None;
|
||||
}
|
||||
let v = k.value()?;
|
||||
Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v })
|
||||
}
|
||||
|
||||
/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`.
|
||||
fn sweep(c: Option<Vec2>, a: Option<Vec2>, b: Option<Vec2>) -> Option<f64> {
|
||||
let (o, pa, pb) = (c?, a?, b?);
|
||||
Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU))
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a
|
||||
//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::solve_counts;
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again;
|
||||
/// returns the session and the extrude's id.
|
||||
fn extruded() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
(s, extrude)
|
||||
}
|
||||
|
||||
/// Counters from here on.
|
||||
fn reset(s: &mut Session) {
|
||||
s.evaluations = 0;
|
||||
solve_counts::take();
|
||||
}
|
||||
|
||||
/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each.
|
||||
fn frames(s: &mut Session, n: usize, live: bool) -> Vec<Value> {
|
||||
(1..=n)
|
||||
.map(|k| {
|
||||
let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]);
|
||||
run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body_contains(s: &Session, p: Vec3) -> bool {
|
||||
s.model.state().bodies.first().expect("a body").body.contains(p)
|
||||
}
|
||||
|
||||
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
|
||||
let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode");
|
||||
sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point")
|
||||
}
|
||||
|
||||
fn corner(s: &Session) -> solvecraft_geom::Vec2 {
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let st = s.world_state();
|
||||
let sk = &st.sketch(id).expect("the shown sketch").sketch;
|
||||
sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() {
|
||||
let (mut s, extrude) = extruded();
|
||||
let body = s.model.state().bodies.first().expect("a body").body.clone();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(s.evaluations, 0, "a live frame evaluates no feature");
|
||||
assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag");
|
||||
// The sketch shows its new place; the extrude keeps its last result.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9);
|
||||
let result = s.model.result(extrude).expect("the extrude's result");
|
||||
assert!(result.stale);
|
||||
assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_end_evaluates_once_to_the_scripted_drags_result() {
|
||||
let (mut live, extrude) = extruded();
|
||||
let (mut scripted, _) = extruded();
|
||||
frames(&mut live, 3, true);
|
||||
reset(&mut live);
|
||||
let end = run(&mut live, "sketch.drag_end", json!({}));
|
||||
assert_eq!(live.evaluations, 1, "one full evaluation at the end");
|
||||
assert_eq!(solve_counts::take(), (0, 0));
|
||||
assert_eq!(end["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!live.model.is_stale());
|
||||
frames(&mut scripted, 3, false);
|
||||
assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`");
|
||||
let (a, b) = (live.model.state(), scripted.model.state());
|
||||
let (sa, sb) = (&a.sketches[0], &b.sketches[0]);
|
||||
assert_eq!(sa.sketch, sb.sketch);
|
||||
assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined));
|
||||
assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed");
|
||||
assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none()));
|
||||
// One undo step for the gesture is the UI's business; each frame is still undoable.
|
||||
run(&mut live, "edit.undo", json!({}));
|
||||
assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_live_drag_is_evaluated_before_the_next_command() {
|
||||
let (mut s, extrude) = extruded();
|
||||
frames(&mut s, 2, true);
|
||||
assert!(s.model.is_stale());
|
||||
reset(&mut s);
|
||||
// A command that changes nothing still sees (and leaves) a fully evaluated model.
|
||||
run(&mut s, "sketch.solve", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5)));
|
||||
// Leaving the sketch does too.
|
||||
frames(&mut s, 1, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scripted_drag_still_evaluates_the_whole_model() {
|
||||
let (mut s, extrude) = extruded();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, false).remove(0);
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis");
|
||||
assert_eq!(r["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_reports_how_far_the_geometry_followed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
for live in [false, true] {
|
||||
// A line of fixed length with a fixed end cannot slide: the sketch's own solve only
|
||||
// turns it a little toward the cursor, and the grab follows its free end.
|
||||
let before = pt(&s, "l1.end");
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live}));
|
||||
assert_eq!(r["reached"], json!(0.0), "{r}");
|
||||
let moved = pt(&s, "l1.end") - before;
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}");
|
||||
let to = 40 + 10 * usize::from(live);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]}));
|
||||
assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}");
|
||||
}
|
||||
// A point that cannot move at all.
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true}));
|
||||
assert_eq!(r["reached"], json!(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
reset(&mut s);
|
||||
frames(&mut s, 2, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert_eq!(solve_counts::take(), (2, 0));
|
||||
// The free rectangle's corner moves alone; its profile follows.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
let st = s.model.state();
|
||||
let profile = st.sketches[0].profiles.first().expect("a profile");
|
||||
assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area);
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
}
|
||||
|
||||
/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each
|
||||
/// frame's `from` is the previous frame's `at`, as the viewport does.
|
||||
fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) {
|
||||
let (mut from, mut cursor) = (grab, grab);
|
||||
for m in moves {
|
||||
cursor = [cursor[0] + m[0], cursor[1] + m[1]];
|
||||
let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true}));
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
from = [at.x, at.y];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grab_follows_the_points_that_move() {
|
||||
// A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the
|
||||
// cursor's offset frame after frame.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]);
|
||||
assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end"));
|
||||
assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_during_a_live_drag_does_not_evaluate_the_model() {
|
||||
let (mut s, _) = extruded();
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
for (id, p) in
|
||||
[("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))]
|
||||
{
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert!(s.model.is_stale(), "the drag goes on");
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
|
||||
fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_the_dimensions_current_values() {
|
||||
let (mut s, _) = extruded();
|
||||
// The bottom side's parameter now says 25 while the sketch still holds 20 mm.
|
||||
let sk = sketch_of(&s);
|
||||
let l1 = sk.curve_index("l1").expect("l1");
|
||||
let param = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1))
|
||||
.and_then(|c| c.param.clone())
|
||||
.expect("l1's dimension");
|
||||
if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) {
|
||||
p.expr = "25".into();
|
||||
}
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_swaps_only_the_dragged_sketch() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch");
|
||||
let before = other(&s);
|
||||
frames(&mut s, 1, true);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied");
|
||||
// Later frames change the state they own in place.
|
||||
let state = std::sync::Arc::as_ptr(&s.model.state());
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letting_go_on_a_snap_or_undoing_evaluates_once() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false}));
|
||||
let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point");
|
||||
frames(&mut s, 3, true);
|
||||
reset(&mut s);
|
||||
let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap }));
|
||||
assert_eq!(s.evaluations, 1, "{r}");
|
||||
assert!(r["constraint"].get("error").is_none(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
// Undo after an abandoned drag.
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
// A snap that over-constrains the sketch is not kept; the drag still ends.
|
||||
frames(&mut s, 1, true);
|
||||
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad }));
|
||||
assert!(r["constraint"]["error"].is_string(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel;
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
|
||||
|
||||
use super::sketch::{add_c, edit, ids_of};
|
||||
use super::sketch::{add_c, edit, ids_of, solve_current};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
@@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.extend";
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
|
||||
}
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
@@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pin(sk, end, oi)?;
|
||||
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
|
||||
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
|
||||
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|
||||
if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
|
||||
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|
||||
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
|
||||
{
|
||||
@@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let side = p.get("side").and_then(vec2);
|
||||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve existing constraints before offsetting"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
@@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
if let Some(association) = sk
|
||||
|
||||
@@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() {
|
||||
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// How the arm's link lengths are set.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Links {
|
||||
/// Length dimensions on both links.
|
||||
Dimensioned,
|
||||
/// Equal to fixed reference lines (no length dimension anywhere).
|
||||
EqualToFixed,
|
||||
}
|
||||
|
||||
/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged
|
||||
/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The
|
||||
/// elbow has two solutions for every hand position; a drag must keep the one it started on.
|
||||
/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short).
|
||||
fn arm_drags(links: Links) -> (usize, usize) {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let mut s = new_sketch();
|
||||
// Away from the sketch origin so nothing connects to it.
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
match links {
|
||||
Links::Dimensioned => {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
}
|
||||
Links::EqualToFixed => {
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"}));
|
||||
}
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned));
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end"));
|
||||
let p2b = ix("l2.start");
|
||||
let side = |sk: &Sketch| {
|
||||
let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos);
|
||||
(x3 - x1).cross(x2 - x1).signum()
|
||||
};
|
||||
let mut seed = 11u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips, mut short) = (0, 0, 0);
|
||||
while kept < 1000 {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&sketch(&s));
|
||||
drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]);
|
||||
let sk = sketch(&s);
|
||||
if side(&sk) != before {
|
||||
flips += 1;
|
||||
}
|
||||
if sk.points[p3].pos.dist(to) > 1e-6 {
|
||||
short += 1;
|
||||
}
|
||||
}
|
||||
(flips, short)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_two_link_arm_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = arm_drags(links);
|
||||
eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each step of a drag is seeded on the positions from before it, so even a drag made in one
|
||||
/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are
|
||||
/// dimensioned or held equal to fixed lines (the seed reads both).
|
||||
#[test]
|
||||
fn a_single_step_arm_drag_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links));
|
||||
eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2)
|
||||
/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged
|
||||
/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n`
|
||||
/// deterministic cases with the triangle at random turns and on either side. Returns the drags
|
||||
/// that turned the triangle over.
|
||||
fn hanging_triangle_flips(n: usize) -> usize {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end"));
|
||||
let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end"));
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let mut seed = 23u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips) = (0, 0);
|
||||
while kept < n {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
let turn = rnd() * TAU;
|
||||
let u = solvecraft_geom::Vec2::from_angle(turn);
|
||||
let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt());
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]}));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end"));
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the
|
||||
/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over,
|
||||
/// however few steps the drag takes.
|
||||
#[test]
|
||||
fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() {
|
||||
let flips: Vec<(usize, usize)> =
|
||||
[1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect();
|
||||
eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}");
|
||||
assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}");
|
||||
}
|
||||
|
||||
/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one
|
||||
/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave
|
||||
/// the elbow placed there; returns how many of the sketch's own solves that follow put it on
|
||||
/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`.
|
||||
fn pinned_elbow_flips(n: usize, move_point: bool) -> usize {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
let start = s.doc.clone();
|
||||
let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum();
|
||||
let before = side(&s);
|
||||
let mut flips = 0;
|
||||
for i in 0..n {
|
||||
s.doc = start.clone();
|
||||
let elbow = pt(&s, "l1.end");
|
||||
// Across the line, spread along it.
|
||||
let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64);
|
||||
let r = if move_point {
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]}))
|
||||
} else {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]}))
|
||||
};
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps
|
||||
/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged
|
||||
/// across the shoulder-hand line in one step, stays on its side.
|
||||
#[test]
|
||||
fn a_failed_drag_step_keeps_the_last_solved_orientation() {
|
||||
for move_point in [false, true] {
|
||||
let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point));
|
||||
eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips");
|
||||
assert_eq!(flips, 0, "move_point {move_point}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a failed step the drag follows as far as the geometry can, and `reached` says how far:
|
||||
/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about
|
||||
/// two thirds of the way.
|
||||
#[test]
|
||||
fn a_drag_follows_up_to_where_its_step_fails() {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
// Elbow bent so the hand is 49 from the shoulder, along +x.
|
||||
let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0);
|
||||
let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt());
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]}));
|
||||
let reached = r["reached"].as_f64().unwrap_or(f64::NAN);
|
||||
eprintln!("arm hand past its reach in one step: {r}");
|
||||
assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}");
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
}
|
||||
|
||||
/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is
|
||||
/// restored when the capped code panics.
|
||||
#[test]
|
||||
fn the_step_cap_leaves_the_plan_and_is_restored() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
let uncapped = super::drag::step_cap::get();
|
||||
let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]);
|
||||
assert!(plan > 1);
|
||||
assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan);
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err());
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
}
|
||||
|
||||
/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360).
|
||||
fn sweep_deg(s: &Session, arc: &str) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") };
|
||||
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
|
||||
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
|
||||
}
|
||||
|
||||
/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°.
|
||||
fn free_arc(sweep: f64) -> Session {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn on_arc(deg: f64) -> [f64; 2] {
|
||||
let t = deg.to_radians();
|
||||
[50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]
|
||||
}
|
||||
|
||||
/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or
|
||||
/// 330° → 360° → 30°): the arc stops short, in one call or in many.
|
||||
#[test]
|
||||
fn a_dragged_arc_end_never_wraps_through_its_start() {
|
||||
// One call, clockwise through the start: 90° → -60°.
|
||||
let mut s = free_arc(90.0);
|
||||
drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°");
|
||||
assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°");
|
||||
// One call, counter-clockwise past a full turn: 330° → 390°.
|
||||
let mut s = free_arc(330.0);
|
||||
drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°");
|
||||
assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°");
|
||||
// 30° calls, clockwise: 90 → -60.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in [60.0, 30.0, 0.0, -30.0, -60.0] {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}");
|
||||
// 30° calls, counter-clockwise: 90 → 450.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in (4..=15).map(|i| (i * 30) as f64) {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}");
|
||||
assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}");
|
||||
}
|
||||
|
||||
/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner
|
||||
/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns
|
||||
/// over near 14° (the tangent length passes 40) and comes back on the way up.
|
||||
#[test]
|
||||
fn a_dragged_fillet_stays_in_its_corner() {
|
||||
for (leg, strict) in [(80.0, true), (40.0, false)] {
|
||||
let mut s = new_sketch();
|
||||
let o = v(100.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"}));
|
||||
let sk0 = sketch(&s);
|
||||
let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc");
|
||||
let arc_id = sk0.curves[arc].id.clone();
|
||||
let inside = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum();
|
||||
let c = sk.center(arc).expect("centre");
|
||||
let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end"));
|
||||
let r = sk.radius(arc).unwrap_or(0.0);
|
||||
side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6
|
||||
};
|
||||
assert!(inside(&s));
|
||||
let mut path: Vec<f64> = (45..=87).map(|i| (i * 2) as f64).collect();
|
||||
path.extend((7..=87).rev().map(|i| (i * 2) as f64));
|
||||
path.extend((8..=45).map(|i| (i * 2) as f64));
|
||||
let corner = o;
|
||||
let at = |deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
[corner.x + leg * t.cos(), corner.y + leg * t.sin()]
|
||||
};
|
||||
let mut bad = Vec::new();
|
||||
let mut last = at(90.0);
|
||||
for deg in path {
|
||||
let to = at(deg);
|
||||
drag(&mut s, "l2.end", last, to);
|
||||
last = to;
|
||||
let sw = sweep_deg(&s, &arc_id);
|
||||
if !(inside(&s) && sw < 180.0) {
|
||||
bad.push(deg);
|
||||
}
|
||||
}
|
||||
if strict {
|
||||
assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}");
|
||||
}
|
||||
assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing.
|
||||
#[test]
|
||||
fn hostile_drags_change_nothing() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
|
||||
let l0 = snap(&s, "l1");
|
||||
for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] {
|
||||
for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] {
|
||||
for e in ["l1", "l1.end"] {
|
||||
let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()}));
|
||||
assert!(r.is_err(), "{e} {from} {to}: {r:?}");
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
}
|
||||
assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err());
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]);
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]}));
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
// A huge (but finite, in-range) jump on a constrained group is capped and never panics.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]);
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// The solves a drag of `entity` from `from` to `to` would take.
|
||||
fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize {
|
||||
let sk = sketch(s);
|
||||
let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}"));
|
||||
super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1]))
|
||||
}
|
||||
|
||||
/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one
|
||||
/// held solve, as it was before; only a long one is split.
|
||||
#[test]
|
||||
fn a_short_drag_is_a_single_solve() {
|
||||
let mut s = new_sketch();
|
||||
// Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin).
|
||||
for i in 0..10 {
|
||||
let x = 100.0 + 30.0 * i as f64;
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]}));
|
||||
let c = curve_ids(&r);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10}));
|
||||
}
|
||||
assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1);
|
||||
assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1);
|
||||
drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]);
|
||||
assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9);
|
||||
// A long move (more than an eighth of the 10 mm side) is split into steps.
|
||||
assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1);
|
||||
drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]);
|
||||
assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that
|
||||
/// only shares a fixed point with the dragged geometry does not shrink its steps.
|
||||
#[test]
|
||||
fn the_drag_step_ignores_geometry_beyond_a_fixed_point() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01}));
|
||||
assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before);
|
||||
assert_eq!(before, 1);
|
||||
// A free radius couples like a point: a line tangent to a circle whose centre is fixed
|
||||
// steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"}));
|
||||
assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6);
|
||||
}
|
||||
|
||||
/// The step counts every size the constraints hold, offsets along an axis too (the solver holds
|
||||
/// their size on either side), except a length between two points the drag holds: a dimensioned
|
||||
/// line slid by its middle keeps its length whatever the step.
|
||||
#[test]
|
||||
fn the_drag_step_counts_every_held_size_but_the_dragged_length() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5}));
|
||||
let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]);
|
||||
eprintln!("0.5 mm line slid 5 mm: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
// B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays
|
||||
// right of A instead of jumping to the other side.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. })));
|
||||
let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start"));
|
||||
eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}");
|
||||
assert!(steps > 1);
|
||||
assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// A circle shares only its centre with a dragged line's end (coincident): the solve couples
|
||||
/// its centre, never its radius, so a tiny circle does not shrink the step.
|
||||
#[test]
|
||||
fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. })));
|
||||
let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
}
|
||||
|
||||
/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand
|
||||
/// through the hole round its shoulder) still lands on a cursor it can reach.
|
||||
#[test]
|
||||
fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// From (30, 20) to (-30, -20) about the shoulder: the path passes through it.
|
||||
let to = [o.x - 30.0, o.y - 20.0];
|
||||
let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to}));
|
||||
eprintln!("arm hand across the hole: {steps} steps, {r}");
|
||||
assert!(steps > 1);
|
||||
assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to
|
||||
/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side.
|
||||
#[test]
|
||||
fn a_drag_past_an_unreachable_stretch_keeps_stepping() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let elbow = o + v(19.702247520273062, 22.62347105662377);
|
||||
let hand = o + v(39.654744430607906, 21.245840103473398);
|
||||
let to = o + v(-28.82686238241851, -31.24580379872583);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// A triangle with sides 3.9, 2 and 2 on the hand.
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]}));
|
||||
eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s));
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(side(&s), before, "the triangle turned over");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at
|
||||
/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged.
|
||||
#[test]
|
||||
fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() {
|
||||
// (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad
|
||||
// of the limit).
|
||||
for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] {
|
||||
let mut s = free_arc(sweep);
|
||||
// A dimensioned line hangs off the arc's end.
|
||||
let end = pt(&s, "a1.end");
|
||||
run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()}));
|
||||
let sw0 = sweep_deg(&s, "a1");
|
||||
// The arc's own end.
|
||||
let t = (end - v(50.0, 50.0)).angle() + nudge;
|
||||
let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()];
|
||||
drag(&mut s, "a1.end", [end.x, end.y], to);
|
||||
assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move");
|
||||
// The joined line's far end, which pulls the arc's end along (inwards).
|
||||
let far = pt(&s, "lc.end");
|
||||
let target = v(far.x, far.y + 1e-5 * nudge.signum());
|
||||
drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]);
|
||||
assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move");
|
||||
assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_constrained_feedback_is_per_entity() {
|
||||
let mut s = new_sketch();
|
||||
@@ -669,3 +1247,84 @@ fn pipe_and_sweep_follow_3d_sketch_curves() {
|
||||
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
|
||||
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
|
||||
}
|
||||
|
||||
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
|
||||
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
|
||||
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
|
||||
fn hung_arm() -> Session {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let at = |x: f64, y: f64| json!([o.x + x, o.y + y]);
|
||||
run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]}));
|
||||
for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The elbow's side of the shoulder–hand line.
|
||||
fn hung_elbow_side(s: &Session) -> f64 {
|
||||
let b = pt(s, "ab.end");
|
||||
(pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum()
|
||||
}
|
||||
|
||||
/// The hand of `hung_arm` swung by `angle` about the shoulder.
|
||||
fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 {
|
||||
let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end"));
|
||||
let r = h - b;
|
||||
let (c, si) = (angle.cos(), angle.sin());
|
||||
b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale
|
||||
}
|
||||
|
||||
/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short
|
||||
/// steps (continuation keeps the elbow the seed no longer places).
|
||||
#[test]
|
||||
fn a_drag_out_of_decomposition_budget_takes_short_steps() {
|
||||
let start = hung_arm();
|
||||
let before = hung_elbow_side(&start);
|
||||
let h = pt(&start, "eh.end");
|
||||
let mut flips = 0;
|
||||
for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] {
|
||||
let mut s = hung_arm();
|
||||
let to = hung_hand_target(&s, k * 0.7, 1.0);
|
||||
let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]);
|
||||
let r = super::drag::last_drag::with_frame_work(0, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.cut, "{d:?}");
|
||||
assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps");
|
||||
assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}");
|
||||
if hung_elbow_side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(flips, 0);
|
||||
}
|
||||
|
||||
/// All the held solves of one live frame, halvings included, share one decomposition budget:
|
||||
/// together they do at most a frame's work, and one solve's more.
|
||||
#[test]
|
||||
fn a_live_drag_shares_one_decomposition_budget() {
|
||||
let mut s = hung_arm();
|
||||
let h = pt(&s, "eh.end");
|
||||
let near = hung_hand_target(&s, 0.01, 1.0);
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true}));
|
||||
let one = super::drag::last_drag::get();
|
||||
assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}");
|
||||
// Swung round and out past the arm's reach: many steps, then halvings.
|
||||
let mut s = hung_arm();
|
||||
let far = hung_hand_target(&s, 2.0, 1.5);
|
||||
let budget = 3 * one.work;
|
||||
super::drag::last_drag::with_frame_work(budget, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.count > 6, "{d:?}");
|
||||
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
|
||||
}
|
||||
@@ -217,6 +217,19 @@ pub struct Session {
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
next_checkpoint: u64,
|
||||
/// Full model evaluations (`refresh`), for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) evaluations: usize,
|
||||
}
|
||||
|
||||
/// A sketch changed by one frame of an interactive gesture (`live: true`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiveEdit {
|
||||
pub sketch: u64,
|
||||
/// What the frame's solve found: a full solve's report, or, when the sketch was solved
|
||||
/// without a rank analysis, its last report's degrees of freedom and determined flags (a
|
||||
/// drag does not change them; the evaluation at the gesture's end brings them up to date).
|
||||
pub report: solvecraft_sketch::SolveReport,
|
||||
}
|
||||
|
||||
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
|
||||
@@ -302,6 +315,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +375,7 @@ impl Session {
|
||||
if let Some(inverse) = solvecraft_doc::mat_inverse(&first) {
|
||||
let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse);
|
||||
for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) {
|
||||
let sketch = Arc::make_mut(sketch);
|
||||
let plane = sketch.plane;
|
||||
if let Some(mapped) = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&delta, plane.origin),
|
||||
@@ -394,6 +410,10 @@ impl Session {
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
self.model.evaluate(&self.doc);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.evaluations += 1;
|
||||
}
|
||||
self.revision += 1;
|
||||
self.prune_occurrence_state();
|
||||
// Joints follow the geometry their origins snap to.
|
||||
@@ -407,10 +427,65 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the sketch a live frame changed, leaving the features after it as they were last
|
||||
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
|
||||
fn refresh_live(&mut self, edit: LiveEdit) {
|
||||
let sketch = self.doc.sketch(edit.sketch).ok().cloned();
|
||||
if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) {
|
||||
self.revision += 1;
|
||||
} else {
|
||||
self.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketches whose last evaluation solved, each with its own parameter-driven values
|
||||
/// (taken before a value edit, for [`Session::settle_value_edit`]).
|
||||
pub(crate) fn solved_sketches(&self) -> SketchValues {
|
||||
self.model
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|r| r.state.sketch(r.id).filter(|k| k.report.keeps_orientation()).map(|k| (r.id, own_values(&k.sketch))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// After the document's values changed (parameters, a configuration, the units): evaluate,
|
||||
/// and refuse the edit when a sketch that solved before no longer does, or turns over,
|
||||
/// because its own parameter-driven values changed (the command fails, so `execute`
|
||||
/// restores the document's values and geometry). A sketch broken by a change upstream
|
||||
/// (a projected or linked edge moved) is not the edit's to refuse: it carries a warning,
|
||||
/// as before. Otherwise store each sketch whose values changed as solved, so the next edit
|
||||
/// continues from it (see `Model::adopt_solved_sketches`).
|
||||
pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> {
|
||||
self.refresh();
|
||||
for r in &self.model.results {
|
||||
let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue };
|
||||
if !k.report.keeps_orientation() && own_values(&k.sketch) != *before {
|
||||
let why = k.report.failure(|f| format!("its constraints would conflict ({f})"));
|
||||
return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name)));
|
||||
}
|
||||
}
|
||||
// Checked first: storing makes the document unique (a copy when the undo step shares it).
|
||||
if self.model.edited_sketches(&self.doc).next().is_some() {
|
||||
self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
|
||||
/// when they change the document; a failing or panicking command leaves it unchanged.
|
||||
///
|
||||
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
|
||||
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
|
||||
/// feature's last result (stale) instead of evaluating them. Any other command that reads
|
||||
/// the model ([`CommandSpec::reads_model`]) first evaluates a stale model in full, so the
|
||||
/// gesture's end (`sketch.drag_end`, or whatever runs next after an abandoned gesture) brings
|
||||
/// the model up to date before anything reads it.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
|
||||
if live.is_none() && spec.reads_model && self.model.is_stale() {
|
||||
self.refresh();
|
||||
}
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
@@ -425,7 +500,10 @@ impl Session {
|
||||
let (_nested, outermost) = frames::Nested::enter(spec.id);
|
||||
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
|
||||
let params = mapped.as_ref().unwrap_or(params);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live {
|
||||
Some(run) => run(self, params),
|
||||
None => (spec.run)(self, params).map(|v| (v, None)),
|
||||
}));
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -435,7 +513,7 @@ impl Session {
|
||||
};
|
||||
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
|
||||
match r {
|
||||
Ok(v) => {
|
||||
Ok((v, live_edit)) => {
|
||||
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
|
||||
if changed {
|
||||
if spec.undoable {
|
||||
@@ -445,7 +523,10 @@ impl Session {
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
self.refresh();
|
||||
match live_edit {
|
||||
Some(edit) => self.refresh_live(edit),
|
||||
None => self.refresh(),
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -548,6 +629,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: self.visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,3 +769,12 @@ mod tests;
|
||||
mod thread_tests;
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests;
|
||||
|
||||
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
|
||||
pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections::BTreeMap<String, f64>>;
|
||||
|
||||
/// The values of a sketch's driving dimensions that come from parameters: what a value edit
|
||||
/// changes in it (as opposed to geometry it projects or links from upstream).
|
||||
fn own_values(sk: &solvecraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
|
||||
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect()
|
||||
}
|
||||
@@ -1677,8 +1677,10 @@ fn cached_evaluation_matches_from_scratch() {
|
||||
let before = hits(&s);
|
||||
run(&mut s, id, p.clone());
|
||||
same(&s, &format!("step {step}: {id} {p}"));
|
||||
if step == 1 {
|
||||
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
|
||||
// Setting the width back solves the sketch again from its current state; the
|
||||
// features after it see the same geometry and reuse their results. So does undo.
|
||||
if step == 1 || step == 3 {
|
||||
assert!(hits(&s) > before, "step {step} reuses the earlier results");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Solve times of an n×n grid (origin corner fixed, every row and column edge horizontal or
|
||||
//! vertical with a distance), started at the solution and ±1 mm off it, and of one held drag
|
||||
//! frame on a strip of rectangles. Run with
|
||||
//! `cargo run --release -p solvecraft-sketch --example solve_bench`; results go in docs/perf.md.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, Hold, Sketch, SolveReport, solve, solve_with};
|
||||
|
||||
/// A small deterministic generator for the start offsets.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
/// In [-1, 1).
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(n: usize, jitter: f64) -> Result<Sketch, Box<dyn std::error::Error>> {
|
||||
let mut sk = Sketch::new();
|
||||
let mut rnd = Lcg(12345);
|
||||
let mut id = vec![vec![0; n]; n];
|
||||
for (r, row) in id.iter_mut().enumerate() {
|
||||
for (c, slot) in row.iter_mut().enumerate() {
|
||||
let j = if r == 0 && c == 0 { Vec2::ZERO } else { Vec2::new(rnd.next(), rnd.next()) * jitter };
|
||||
*slot = sk.add_point(Vec2::new(c as f64 * 10.0, r as f64 * 10.0) + j, None)?;
|
||||
}
|
||||
}
|
||||
sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None)?;
|
||||
for r in 0..n {
|
||||
for c in 0..n {
|
||||
for (dr, dc, horizontal) in [(0, 1, true), (1, 0, false)] {
|
||||
let (Some(&p), Some(&q)) = (id.get(r).and_then(|x| x.get(c)), id.get(r + dr).and_then(|x| x.get(c + dc))) else { continue };
|
||||
let l = sk.add_line_pts(p, q, None)?;
|
||||
sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sk)
|
||||
}
|
||||
|
||||
/// `n` rectangles side by side sharing their vertical edges: horizontal and vertical sides, every
|
||||
/// width dimensioned, the first height too. Returns the sketch and its first corner.
|
||||
fn strip(n: usize) -> Result<(Sketch, usize), Box<dyn std::error::Error>> {
|
||||
let mut sk = Sketch::new();
|
||||
let bottom: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 10.0), None)).collect::<Result<_, _>>()?;
|
||||
let top: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 25.0), None)).collect::<Result<_, _>>()?;
|
||||
for i in 0..=n {
|
||||
let l = sk.add_line_pts(bottom[i], top[i], None)?;
|
||||
sk.add_constraint(ConstraintKind::Vertical { l }, None)?;
|
||||
if i == 0 {
|
||||
sk.add_constraint(ConstraintKind::Length { l, value: 15.0 }, None)?;
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = sk.add_line_pts(bottom[i], bottom[i + 1], None)?;
|
||||
let t = sk.add_line_pts(top[i], top[i + 1], None)?;
|
||||
sk.add_constraint(ConstraintKind::Horizontal { l: b }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Horizontal { l: t }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Length { l: b, value: 20.0 }, None)?;
|
||||
}
|
||||
Ok((sk, bottom[0]))
|
||||
}
|
||||
|
||||
/// Best of `reps` solves of a copy of `sk` (ms) and the last report.
|
||||
fn time(sk: &Sketch, reps: usize, f: impl Fn(&mut Sketch) -> SolveReport) -> (f64, SolveReport) {
|
||||
let mut best = f64::MAX;
|
||||
let mut rep = None;
|
||||
for _ in 0..reps {
|
||||
let mut k = sk.clone();
|
||||
let t = Instant::now();
|
||||
let r = f(&mut k);
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||||
rep = Some(r);
|
||||
}
|
||||
(best, rep.unwrap_or_else(|| solve(&mut sk.clone())))
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("| grid | points | start | ms | status | dof | iterations |");
|
||||
println!("|---|---|---|---|---|---|---|");
|
||||
for n in [10usize, 15, 20] {
|
||||
for jitter in [0.0, 1.0] {
|
||||
let sk = grid(n, jitter)?;
|
||||
let (ms, rep) = time(&sk, 5, solve);
|
||||
let start = if jitter == 0.0 { "at solution" } else { "±1 mm" };
|
||||
println!("| {n}×{n} | {} | {start} | {ms:.2} | {:?} | {} | {} |", n * n, rep.status, rep.dof, rep.iterations);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("| strip | points | held drag frame ms | full solve ms |");
|
||||
println!("|---|---|---|---|");
|
||||
for n in [30usize, 100] {
|
||||
let (mut sk, corner) = strip(n)?;
|
||||
if let Some(p) = sk.points.get_mut(corner) {
|
||||
p.pos -= Vec2::new(0.5, 0.0);
|
||||
}
|
||||
let hold = Hold { points: vec![corner], radii: vec![] };
|
||||
let (frame, rep) = time(&sk, 5, |k| solve_with(k, &hold, None, Analysis::StatusOnly, Some(&mut Budget::frame())));
|
||||
let (full, _) = time(&sk, 5, solve);
|
||||
println!("| {n} rectangles | {} | {frame:.2} ({:?}) | {full:.2} |", sk.points.len(), rep.status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
|
||||
// A profile feature without picks takes the profiles of the active (else the last) sketch:
|
||||
// point at them.
|
||||
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
|
||||
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
|
||||
let ss =
|
||||
app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s));
|
||||
if let Some(ss) = ss {
|
||||
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
|
||||
}
|
||||
|
||||
@@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
let id = egui::Id::new(("pexpr", &p.name));
|
||||
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
|
||||
let refused_id = egui::Id::new(("prefused", &p.name));
|
||||
if r.changed() {
|
||||
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
|
||||
// Typing again clears the reason a previous change was refused.
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(id, e.clone());
|
||||
dd.remove::<String>(refused_id);
|
||||
});
|
||||
}
|
||||
if r.lost_focus() && e != p.expr {
|
||||
let _ = app.run("parameters.change", json!({"name": p.name, "expression": e}));
|
||||
ui.data_mut(|dd| dd.remove::<String>(id));
|
||||
match change_param(app, &p.name, &e) {
|
||||
Ok(()) => ui.data_mut(|dd| {
|
||||
dd.remove::<String>(id);
|
||||
dd.remove::<String>(refused_id);
|
||||
}),
|
||||
// Refused (it would leave a sketch unsolved, say): the typed expression
|
||||
// stays in the field with the reason beside it, nothing silently reverts.
|
||||
Err(why) => ui.data_mut(|dd| {
|
||||
dd.insert_temp(refused_id, why);
|
||||
}),
|
||||
}
|
||||
}
|
||||
let refused = ui.data(|dd| dd.get_temp::<String>(refused_id));
|
||||
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
|
||||
match (v, errs.get(&p.name)) {
|
||||
match (v, errs.get(&p.name).or(refused.as_ref())) {
|
||||
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
|
||||
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
|
||||
_ => ui.label(""),
|
||||
@@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Set a parameter's expression from the parameters table: `Err` with the message to show
|
||||
/// beside the field when the change is refused (the document keeps its values).
|
||||
pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> {
|
||||
app.run("parameters.change", json!({"name": name, "expression": expression}))
|
||||
.map(|_| ())
|
||||
.map_err(|e| crate::friendly_error("parameters.change", &e))
|
||||
}
|
||||
|
||||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
let plane = match sel {
|
||||
@@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.error = None;
|
||||
Some(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod param_refusal_tests {
|
||||
use super::*;
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn a_refused_parameter_change_shows_why() {
|
||||
// A triangle whose sides are parameters: a side longer than the other two together
|
||||
// has no solution, so the change is refused and the table shows the reason.
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "name": "Tri"})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[0, 0], [40, 0], [20, 30]], "closed": true})).unwrap();
|
||||
for l in ["l1", "l2", "l3"] {
|
||||
s.execute("sketch.dimension", &json!({"entities": [l]})).unwrap();
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let name = s.doc.params.iter().find(|p| p.model).map(|p| p.name.clone()).unwrap();
|
||||
let before = s.doc.param(&name).map(|p| p.expr.clone());
|
||||
let mut app = SolveApp::new(s, Default::default());
|
||||
let why = change_param(&mut app, &name, "500 mm").unwrap_err();
|
||||
assert!(why.contains("Tri") && why.contains("conflict"), "{why}");
|
||||
assert_eq!(app.session.doc.param(&name).map(|p| p.expr.clone()), before);
|
||||
assert!(app.status.as_ref().is_some_and(|(m, _, error)| *error && m.contains("Tri")), "{:?}", app.status);
|
||||
assert!(change_param(&mut app, &name, "41 mm").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -258,9 +258,10 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
}
|
||||
|
||||
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
|
||||
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
|
||||
/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`).
|
||||
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool {
|
||||
let Some(d) = DRAWN.with(|d| {
|
||||
let id = hit(press)?;
|
||||
let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?;
|
||||
d.borrow().iter().find(|x| x.id == id).cloned()
|
||||
}) else {
|
||||
return false;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use egui::{Align2, FontId, Shape, Stroke};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
use solvecraft_engine::sketch::{CurveKind, Sketch};
|
||||
|
||||
@@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec<usize>, Vec<usize>) {
|
||||
(own, ex)
|
||||
}
|
||||
|
||||
/// The drag let go: the constraint of the snap it ended on (none for lining up).
|
||||
pub fn release(app: &mut SolveApp) {
|
||||
let Some(s) = current() else { return };
|
||||
/// The drag let go: the constraint of the snap it ended on (none for lining up), as
|
||||
/// `sketch.drag_end` takes it.
|
||||
pub fn release() -> Option<Value> {
|
||||
let s = current()?;
|
||||
clear();
|
||||
let (cmd, params) = match &s.target {
|
||||
Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
|
||||
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
|
||||
Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})),
|
||||
Target::Align => return,
|
||||
Target::Align => return None,
|
||||
};
|
||||
// A snap that would over-constrain the sketch is simply not kept.
|
||||
let _ = app.session.execute(cmd, ¶ms);
|
||||
Some(json!({"command": cmd, "params": params}))
|
||||
}
|
||||
|
||||
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.
|
||||
|
||||
@@ -93,6 +93,8 @@ pub(crate) mod sketch_display;
|
||||
#[cfg(test)]
|
||||
mod sketch_display_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod sketch_drag_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
pub mod sketch_palette;
|
||||
pub(crate) mod sketch_text;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Dragging sketch geometry in the viewport: live frames re-solve the sketch only, letting go
|
||||
//! brings the model up to date in the drag's one undo step, and the grab stays on the geometry.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
fn run(h: &mut Harness, id: &str, p: Value) {
|
||||
if let Err(e) = h.app.run(id, p.clone()) {
|
||||
panic!("{id} {p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(h: &mut Harness, x: f64, y: f64) -> egui::Pos2 {
|
||||
let [sx, sy] = h.at(&json!({"sketch": [x, y]})).expect("on screen");
|
||||
egui::pos2(sx as f32, sy as f32)
|
||||
}
|
||||
|
||||
fn point(h: &Harness, id: &str) -> Vec2 {
|
||||
let sid = h.app.session.active_sketch.expect("sketch mode");
|
||||
let st = h.app.session.world_state();
|
||||
let sk = &st.sketch(sid).expect("the sketch").sketch;
|
||||
sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}"))
|
||||
}
|
||||
|
||||
/// Press at `path[0]` and move through the rest, one frame per event.
|
||||
fn press_and_move(h: &mut Harness, path: &[egui::Pos2]) {
|
||||
let Some(&a) = path.first() else { return };
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
h.app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.app.synthetic.extend(path.iter().skip(1).map(|&p| egui::Event::PointerMoved(p)));
|
||||
play(h);
|
||||
}
|
||||
|
||||
fn release(h: &mut Harness, at: egui::Pos2) {
|
||||
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
play(h);
|
||||
}
|
||||
|
||||
fn play(h: &mut Harness) {
|
||||
while !h.app.synthetic.is_empty() {
|
||||
h.frame();
|
||||
}
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
/// Points from `a` to `b` in `n` equal moves (without `a`).
|
||||
fn steps(a: egui::Pos2, b: egui::Pos2, n: usize) -> Vec<egui::Pos2> {
|
||||
(1..=n).map(|k| a + (b - a) * (k as f32 / n as f32)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewport_drag_solves_the_sketch_live_and_rebuilds_the_model_on_release() {
|
||||
let mut h = Harness::new();
|
||||
run(&mut h, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut h, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
run(&mut h, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
run(&mut h, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
|
||||
run(&mut h, "sketch.finish", json!({}));
|
||||
run(&mut h, "solid.extrude", json!({"distance": 5}));
|
||||
let sketch = h.app.session.doc.features.first().map(|f| f.id).expect("the sketch");
|
||||
h.app.edit_sketch(sketch);
|
||||
h.settle();
|
||||
let undo = h.app.session.undo.len();
|
||||
let a = screen(&mut h, 5.0, 5.0);
|
||||
let b = screen(&mut h, -3.0, -6.0);
|
||||
let mut path = vec![a];
|
||||
path.extend(steps(a, b, 8));
|
||||
press_and_move(&mut h, &path);
|
||||
assert!(h.app.viewport.point_drag.is_some(), "the drag is under way");
|
||||
assert!(h.app.session.model.is_stale(), "the frames updated the sketch only");
|
||||
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1, "{:?}", point(&h, "l1.start"));
|
||||
release(&mut h, b);
|
||||
assert!(h.app.viewport.point_drag.is_none());
|
||||
assert!(!h.app.session.model.is_stale(), "letting go evaluated the model");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for the drag");
|
||||
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1);
|
||||
run(&mut h, "edit.undo", json!({}));
|
||||
assert!(point(&h, "l1.start").dist(Vec2::new(5.0, 5.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_line_that_stops_short_stays_under_the_cursor_when_it_can_follow_again() {
|
||||
let mut h = Harness::new();
|
||||
run(&mut h, "sketch.create", json!({"plane": "XY"}));
|
||||
// Two 10 mm links from the origin hold the start of a horizontal 10 mm line: the line can
|
||||
// slide right until its start is 20 mm from the origin (3 mm).
|
||||
let (x, y) = (15.0, 75f64.sqrt());
|
||||
run(&mut h, "sketch.line", json!({"points": [[0, 0], [10, 0], [x, y], [x + 10.0, y]]}));
|
||||
for l in ["l1", "l2", "l3"] {
|
||||
run(&mut h, "sketch.dimension", json!({"entities": [l], "value": 10}));
|
||||
}
|
||||
run(&mut h, "sketch.constraint.horizontal_vertical", json!({"line": "l3"}));
|
||||
let grab = screen(&mut h, x + 5.0, y);
|
||||
let far = screen(&mut h, x + 11.0, y);
|
||||
let back = screen(&mut h, x + 7.0, y);
|
||||
let mut path = vec![grab];
|
||||
path.extend(steps(grab, far, 8));
|
||||
path.extend(steps(far, back, 4));
|
||||
press_and_move(&mut h, &path);
|
||||
release(&mut h, back);
|
||||
// The cursor went 6 mm right, the line only 3; back at 2 mm right it is under it again.
|
||||
let start = point(&h, "l3.start");
|
||||
assert!(start.dist(Vec2::new(x + 2.0, y)) < 0.1, "{start:?}");
|
||||
assert!(!h.app.session.model.is_stale());
|
||||
}
|
||||
@@ -1887,13 +1887,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let (o, d) = proj.ray(p);
|
||||
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
|
||||
};
|
||||
// The 3D triad, then a dimension's text, follow a drag that starts on them.
|
||||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||||
&& !crate::sketch3d::drag_start(app, &proj, a)
|
||||
&& let Some(q) = on_plane(app, a)
|
||||
// What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry
|
||||
// (before the dimension's lines, which end on the geometry they measure), as for clicks.
|
||||
let grabbed = resp
|
||||
.drag_started_by(egui::PointerButton::Primary)
|
||||
.then(|| ui.input(|i| i.pointer.press_origin()))
|
||||
.flatten()
|
||||
.filter(|a| !crate::sketch3d::drag_start(app, &proj, *a))
|
||||
.map(|a| (a, pick(app, &proj, a).into_iter().next()));
|
||||
if let Some((a, hit)) = &grabbed
|
||||
&& let Some(q) = on_plane(app, *a)
|
||||
{
|
||||
crate::dim_view::drag_start(app, a, q);
|
||||
let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. }));
|
||||
crate::dim_view::drag_start(app, *a, q, on_geometry);
|
||||
}
|
||||
if crate::sketch3d::dragging()
|
||||
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
|
||||
@@ -1908,13 +1914,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::dim_view::drag_to(app, q);
|
||||
}
|
||||
// A point or a curve of the sketch being edited follows a drag that starts on it.
|
||||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||||
if let Some((a, hit)) = grabbed
|
||||
&& !crate::dim_view::dragging()
|
||||
&& !crate::sketch3d::dragging()
|
||||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
let active = app.session.active_sketch;
|
||||
match pick(app, &proj, a).into_iter().next() {
|
||||
match hit {
|
||||
Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => {
|
||||
app.viewport.point_drag = Some((id, app.session.undo.len(), None))
|
||||
}
|
||||
@@ -1950,17 +1955,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some(s) = snap {
|
||||
q += s.offset;
|
||||
}
|
||||
if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() {
|
||||
app.session.undo.truncate(depth + 1);
|
||||
}
|
||||
// Each frame re-solves the sketch only (`live`); the model follows when the drag ends.
|
||||
let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) {
|
||||
Ok(r) => {
|
||||
app.session.undo.truncate(depth + 1);
|
||||
r.get("at").and_then(solvecraft_engine::params::vec2).unwrap_or(q)
|
||||
}
|
||||
Err(_) => from,
|
||||
};
|
||||
// The grab stays on the geometry: what it could not follow this frame it catches up
|
||||
// with on the next one instead of lagging the cursor for the rest of the drag.
|
||||
if grab.is_some() {
|
||||
app.viewport.point_drag = Some((id, depth, Some(q)));
|
||||
app.viewport.point_drag = Some((id, depth, Some(at)));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
if let Some((_, depth, _)) = app.viewport.point_drag.clone() {
|
||||
// Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step.
|
||||
crate::drag_snap::release(app);
|
||||
// Letting go on a point, midpoint or curve adds the matching constraint, then the
|
||||
// whole model follows the drag, all in the drag's undo step.
|
||||
let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() }));
|
||||
app.session.undo.truncate(depth + 1);
|
||||
}
|
||||
crate::drag_snap::clear();
|
||||
@@ -1969,6 +1982,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::sketch3d::drag_end();
|
||||
}
|
||||
}
|
||||
// A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here,
|
||||
// so the model does not stay behind its sketch until the next command. Not while CAD input
|
||||
// is held back (an issue report's capture): a release then belongs to the report, and the
|
||||
// drag continues afterwards.
|
||||
if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) {
|
||||
app.viewport.point_drag = None;
|
||||
crate::drag_snap::clear();
|
||||
let _ = app.session.execute("sketch.drag_end", &json!({}));
|
||||
}
|
||||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||||
if app.tool.is_none()
|
||||
&& primary_nav.is_none()
|
||||
|
||||
Reference in new issue
Block a user