feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341)
* feat(sketch): constructive solving ahead of the numeric solver Part 1 of splitting #251: the sketch crate only. A graph-constructive front end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the rest, and the rank analysis stays authoritative for degrees of freedom. Solves can follow a reference (the previous solution), so edits and drags keep their branch instead of flipping. New API: solve_with (hold, reference, analysis, budget) and solve_after_value_edit; solve and solve_holding keep their signatures as wrappers, so no caller changes here. When the seed moves an arc's centre, the arc's unconstructed ends start carried along (the arc translates) instead of left behind for LM to turn; found by the reference test 20-slot-constrained. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(doc): keep a sketch's solved state across value edits Part 2 of splitting #251 (builds on part 1, the sketch crate): - Sketch edits solve for the document's values continuing from the stored solved state (Document::solve_sketch), so a dimension edit keeps the sketch's branch instead of re-solving from scratch. - A value edit (parameters, a configuration, the units) that would make a sketch that solved before fail or turn over is refused with the conflicting constraints named; otherwise the solved sketches are written back into the document (Model::adopt_solved_sketches), which changes what saved designs store. - Later features are keyed by a sketch's solved geometry (to a 1e-6 mm quantum), so setting a value back reuses their earlier results. - Solved sketches in a model state are shared (Arc) between states. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(sketch): live drag that keeps its solution, ended by sketch.drag_end Part 3 of splitting #251 (builds on part 2): - sketch.drag follows a long move in short held steps, so the geometry keeps its branch, and reports how far it got (reached, at). - live: true runs one frame of an interactive drag: the sketch is solved once and shown, later features keep their last result (marked stale) until sketch.drag_end or any command that reads the model evaluates it (CommandSpec::live, reads_model, doc_only). - sketch.drag_end adds the constraint the drag let go on and evaluates the model once. - Undo and redo evaluate a new document once (execute does) instead of twice. - The viewport drives drags through live frames and drag_end; dimension text drags start only off sketch geometry. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75a5609b23
commit
fe4d171e22
47 files changed
+10385
-523
No files matched your search
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user