Place a dimension that would over-constrain the sketch as a driven one (#366)

Sketch Dimension refused a dimension the sketch couldn't take, with
"that dimension would over-constrain the sketch; add it as a driven
(reference) dimension with driven: true", which the tool gave no way to
do. It now places it as a driven dimension instead: while placing, the
preview shows it in parentheses in the driven colour, and the click adds
it at once (it has no parameter and no value to edit), with a status line
saying why. Other refusals, such as an unsupported pick, are reported as
before, since the driven dimension is refused too.

Refs #320

Co-authored-by: Vitali M. <[email protected]>
This commit is contained in:
Vitali M.andVitali M. authored and GitHub committed 2026-10-12 14:54:34 -04:00
1 parent c51735c885
commit 6efbd6ff14
3 files changed
+100 -6

No files matched your search

+66 -6
View File
@@ -63,6 +63,9 @@ struct DimensionDraft {
preview_doc: Arc<solvecraft_engine::doc::Document>,
preview: Arc<solvecraft_engine::doc::ModelState>,
error: Option<String>,
/// A dimension that would over-constrain the sketch, placed as a driven (reference) one:
/// it has no parameter and no value to edit (#320).
driven: bool,
}
impl DimensionDraft {
@@ -102,8 +105,15 @@ pub fn cancel() {
/// Infer the measured value through the same command as scripts, without creating a real
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
/// A driven dimension has no value to accept, so it is added at once.
pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
let (param, expr, draft) = dimension_draft(app, entities, at)?;
if draft.driven {
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
app.run("sketch.dimension", json!({"entities": draft.entities, "text_at": [at.x, at.y], "driven": true}))?;
app.set_status("That dimension would over-constrain the sketch, so it was added as a driven (reference) dimension", false);
return Ok(());
}
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(())
@@ -114,22 +124,41 @@ fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(S
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
return Err("Pick one edge or two endpoints, then place the dimension".into());
}
// A dimension the sketch can't take (it would over-constrain it) becomes a driven one,
// which only measures; when that fails too, the first refusal is the one to report.
draft_of(app, sketch, &entities, at, false).or_else(|error| draft_of(app, sketch, &entities, at, true).map_err(|_| error))
}
/// The draft of one dimension: its parameter (for a driven one, its constraint id, since it
/// has no parameter), the parameter's expression, and the scratch session it was added in.
fn draft_of(app: &SolveApp, sketch: u64, entities: &[String], at: Vec2, driven: bool) -> Result<(String, String, DimensionDraft), String> {
let mut scratch = app.session.scratch();
let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?;
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
let expr = scratch.doc.param(&param).ok_or("The dimension value is unavailable")?.expr.clone();
let result =
scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y], "driven": driven})).map_err(|e| e.to_string())?;
let (param, expr) = if driven {
let before =
app.session.doc.sketch(sketch).ok().map(|sk| sk.constraints.iter().map(|c| c.id.clone()).collect::<Vec<_>>()).unwrap_or_default();
let added =
scratch.doc.sketch(sketch).ok().and_then(|sk| sk.constraints.iter().find(|c| c.driven && !before.contains(&c.id)).map(|c| c.id.clone()));
(added.ok_or("The driven dimension is unavailable")?, String::new())
} else {
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
let expr = scratch.doc.param(&param).ok_or("The dimension value is unavailable")?.expr.clone();
(param, expr)
};
let draft = DimensionDraft {
baseline: app.session.doc.clone(),
sketch,
component: app.session.active_component,
path: app.session.active_occurrence_path.clone(),
pending: app.session.pending_moves.clone(),
entities,
entities: entities.to_vec(),
at,
checked_expr: expr.clone(),
preview: scratch.world_state(),
preview_doc: scratch.doc.clone(),
error: None,
driven,
};
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
return Err("That dimension conflicts with the sketch constraints".into());
@@ -368,7 +397,9 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch));
let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::<Vec<_>>();
if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) {
constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true)));
// A driven draft is keyed by its constraint id (it has no parameter).
let ours = |c: &&solvecraft_engine::sketch::Constraint| c.param.as_deref() == Some(param.as_str()) || (c.driven && c.id == *param);
constraints.extend(source.sketch.constraints.iter().filter(ours).map(|c| (source, c, true)));
}
for (source, c, is_draft) in constraints {
if ss.sketch.view.hide_dimensions && !is_draft {
@@ -605,6 +636,35 @@ mod tests {
cancel();
}
/// Placing a dimension the sketch can't take previews it as driven: in the draft it is a
/// driven constraint with no parameter, labelled in parentheses (#320).
#[test]
fn an_over_constraining_placement_previews_a_driven_dimension() {
cancel();
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
let baseline = h.app.session.doc.to_json();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
h.step(&json!({"move":{"sketch":[20,20]}})).unwrap();
h.settle();
let (id, draft) = placement_preview(&h.app).expect("an over-constraining dimension still previews");
assert!(draft.driven);
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
let c = sk.constraints.iter().find(|c| c.id == id).unwrap();
assert!(c.driven && c.param.is_none(), "{c:?}");
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
assert_eq!(label(&c.kind, None, c.driven, "mm", 2), "(20.00)");
assert!(!editing());
assert_eq!(h.app.session.doc.to_json(), baseline);
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
cancel();
}
#[test]
fn placement_follows_real_pointer_without_editing_or_mutating_design() {
cancel();
@@ -348,3 +348,33 @@ fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() {
}
}
}
/// A dimension that would over-constrain the sketch is placed as a driven (reference) one
/// instead of failing: added with no parameter or value editor, and removed again by one
/// undo (#320).
#[test]
fn an_over_constraining_dimension_is_placed_as_a_driven_one() {
let mut h = line([20., 0.]);
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
h.settle();
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let params = h.app.session.doc.params.len();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
h.step(&json!({"move":{"world":[10,6,0]}})).unwrap();
h.settle();
assert_eq!(crate::dim_view::drawn().len(), 1, "the preview is not a selectable dimension");
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
assert!(!crate::dim_view::editing(), "a driven dimension has no value to edit");
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.params.len(), params, "a driven dimension adds no parameter");
let state = h.app.session.world_state();
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert!(solved.report.ok());
let dims: Vec<_> = solved.sketch.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 2);
assert_eq!(dims.iter().filter(|c| c.driven && c.param.is_none()).count(), 1, "{dims:?}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
}
+4
View File
@@ -112,6 +112,10 @@ Between two points, or on a line, that don't line up with an axis, where you pla
dimension picks what it measures: across from them, the distance between them; above or below
them, the horizontal distance; beside them, the vertical distance.
A dimension the sketch can't take, because the geometry is already fixed by other constraints
and dimensions, is added as a driven (reference) dimension instead: it is shown in
parentheses, measures the sketch, and has no value to edit.
For **Coincident**, pick two points, a point and a curve (in either order), or two
distinct straight lines. Two lines join their closest pair of endpoints; the first
picked line stays in place when the existing constraints allow it. Pick endpoints