A tool window for sketch Offset, with a typed Distance, Flip and Chain (#731)

Sketch Offset gets its own tool window with a typed Distance, Flip and Chain, as a variant of the sketch modify window.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 12:46:24 -04:00
1 parent 1a0100cdd9
commit beca7eb126
10 files changed
+239 -69

No files matched your search

+38 -2
View File
@@ -8,7 +8,7 @@
use egui::{Color32, Pos2, Stroke, vec2};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::{Mesh, Vec3};
use solidcraft_engine::geom::{Mesh, Vec2, Vec3};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
@@ -50,7 +50,17 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
Sel::SketchCurve { id } => {
let ss = st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some())?;
let pts = ss.sketch.polyline(ss.sketch.curve_index(id)?);
(ss.plane.to_world(*pts.get(pts.len() / 2)?), None)
let (mid, tangent) = poly_mid(&pts)?;
// An offset's drag arrow: the in-plane direction the distance grows along. The
// command offsets a picked curve to its left, and the window's Flip row reverses it.
let dir = match &d.kind {
Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, .. }) => {
let n = if *flip { -tangent.perp() } else { tangent.perp() };
Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid))
}
_ => None,
};
(ss.plane.to_world(mid), dir)
}
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
Sel::Plane { name } => {
@@ -75,10 +85,36 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
Kind::Plastic(_) => (at, normal),
Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)),
// Already computed above, per picked curve; keep it rather than the default below.
Kind::SketchModify(_) => (at, normal),
_ => (at, None),
})
}
/// The point halfway along a polyline, with the unit tangent there. Indexing the middle sample
/// instead would land on a two-point line's far end, putting the value box and its arrow on a
/// corner. None for a polyline with no length.
fn poly_mid(poly: &[Vec2]) -> Option<(Vec2, Vec2)> {
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
if !total.is_finite() || total <= 0.0 {
return None;
}
let half = total / 2.0;
let mut walked = 0.0;
let mut last = None;
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let Some(dir) = (b - a).normalized() else { continue };
let len = a.dist(b);
if walked + len >= half {
return Some((a + dir * (half - walked), dir));
}
walked += len;
last = Some((b, dir));
}
last
}
fn profile_plane(app: &SolveApp, sketch: u64) -> Option<solidcraft_engine::geom::Plane> {
let component = app.session.doc.feature(sketch)?.component;
let state = app.session.model.state();
+8 -1
View File
@@ -248,7 +248,7 @@ pub enum Kind {
/// Edit Text: a sketch text's content and placement (`sketch_text`).
Text(crate::sketch_text::Tx),
TextMove(crate::sketch_text_move::TextMove),
/// Sketch-space modify tools: scale, and later offset, fillet, chamfer, the patterns,
/// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns,
/// mirror and move (`sketch_modify_ui`).
SketchModify(crate::sketch_modify_ui::Mod),
}
@@ -918,6 +918,13 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
}
/// A checkbox row. Its row handle (`dialog:<row label>`) lets a scenario click it by the label
/// beside it, the way `field` already publishes one for a value field.
pub(crate) fn check(ui: &mut egui::Ui, on: &mut bool) {
let r = ui.checkbox(on, "");
publish_row(&r);
}
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
let row = ROW.with(|r| r.borrow().clone());
let selected = labels.get(*sel).copied().map(tr).unwrap_or("");
+3
View File
@@ -46,6 +46,7 @@
"ui.cancel": "Abbrechen",
"ui.canvases": "Kanvas",
"ui.centerline": "Mittellinie",
"ui.chain": "Kette",
"ui.chamfer": "Abschrägung",
"ui.change_workspace": "Arbeitsbereich wechseln",
"ui.chord_length": "Sehnenlänge",
@@ -74,6 +75,7 @@
"ui.create_sketch": "Skizze erstellen",
"ui.current": "aktuell",
"ui.curvature": "Krümmung",
"ui.curves": "Kurven",
"ui.cut": "Ausschneiden",
"ui.cylinder": "Zylinder",
"ui.dark": "Dunkel",
@@ -143,6 +145,7 @@
"ui.heading_mirror": "SPIEGELN",
"ui.heading_modify": "ÄNDERN",
"ui.heading_move": "VERSCHIEBEN",
"ui.heading_offset": "ABSTAND",
"ui.heading_offset_plane": "ABSTANDSEBENE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETER",
+3
View File
@@ -46,6 +46,7 @@
"ui.cancel": "Cancel",
"ui.canvases": "Canvases",
"ui.centerline": "Centerline",
"ui.chain": "Chain",
"ui.chamfer": "Chamfer",
"ui.change_workspace": "Change workspace",
"ui.chord_length": "Chord Length",
@@ -74,6 +75,7 @@
"ui.create_sketch": "Create Sketch",
"ui.current": "current",
"ui.curvature": "Curvature",
"ui.curves": "Curves",
"ui.cut": "Cut",
"ui.cylinder": "Cylinder",
"ui.dark": "Dark",
@@ -143,6 +145,7 @@
"ui.heading_mirror": "MIRROR",
"ui.heading_modify": "MODIFY",
"ui.heading_move": "MOVE",
"ui.heading_offset": "OFFSET",
"ui.heading_offset_plane": "OFFSET PLANE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETERS",
+3
View File
@@ -46,6 +46,7 @@
"ui.cancel": "キャンセル",
"ui.canvases": "キャンバス",
"ui.centerline": "中心線",
"ui.chain": "チェーン",
"ui.chamfer": "面取り",
"ui.change_workspace": "ワークスペースの変更",
"ui.chord_length": "弦長",
@@ -74,6 +75,7 @@
"ui.create_sketch": "スケッチを作成",
"ui.current": "現在",
"ui.curvature": "曲率",
"ui.curves": "曲線",
"ui.cut": "切り取り",
"ui.cylinder": "円柱",
"ui.dark": "ダーク",
@@ -143,6 +145,7 @@
"ui.heading_mirror": "ミラー",
"ui.heading_modify": "修正",
"ui.heading_move": "移動",
"ui.heading_offset": "オフセット",
"ui.heading_offset_plane": "オフセット平面",
"ui.heading_ok": "OK",
"ui.heading_parameters": "パラメータ",
+3
View File
@@ -46,6 +46,7 @@
"ui.cancel": "취소",
"ui.canvases": "캔버스",
"ui.centerline": "중심선",
"ui.chain": "체인",
"ui.chamfer": "모따기",
"ui.change_workspace": "작업 공간 변경",
"ui.chord_length": "현 길이",
@@ -74,6 +75,7 @@
"ui.create_sketch": "스케치 만들기",
"ui.current": "현재",
"ui.curvature": "곡률",
"ui.curves": "곡선",
"ui.cut": "잘라내기",
"ui.cylinder": "원통",
"ui.dark": "어둡게",
@@ -143,6 +145,7 @@
"ui.heading_mirror": "대칭 복사",
"ui.heading_modify": "수정",
"ui.heading_move": "이동",
"ui.heading_offset": "오프셋",
"ui.heading_offset_plane": "오프셋 평면",
"ui.heading_ok": "확인",
"ui.heading_parameters": "매개변수",
+45 -7
View File
@@ -30,8 +30,26 @@ fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() {
assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. }));
}
/// Curve ids in the open dialog's first selection input.
fn dialog_curves(h: &crate::scenario::Harness) -> Vec<String> {
h.app
.dialog
.as_ref()
.and_then(|d| d.inputs.first())
.map(|i| i.items.iter().filter_map(|x| if let solidcraft_engine::Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect())
.unwrap_or_default()
}
/// Type `value` into the open Offset window's Distance row, then apply with OK.
fn offset_distance(h: &mut crate::scenario::Harness, value: &str) {
h.step(&json!({"click":{"handle":"dialog:Distance"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":value})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
}
#[test]
fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() {
fn offset_window_distance_and_flip_offset_an_ellipse_exactly_and_undo_restores() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
@@ -40,19 +58,25 @@ fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo()
h.step(&json!({"start":"sketch.offset"})).unwrap();
// Major tip is also a source handle: Offset must choose the actual curve.
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]);
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
assert_eq!(dialog_curves(&h), vec!["e1".to_string()], "{:?}", h.app.status);
// Nothing is committed until the window is applied.
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty());
h.step(&json!({"click":{"handle":"dialog:Flip"}})).unwrap();
offset_distance(&mut h, "1 mm");
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status);
assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4);
assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4, "{:?}", sk.freeform_offsets[0].distance);
let first = sk.freeform_offsets[0].output.clone();
let saved = h.app.session.doc.to_json();
h.step(&json!({"start":"sketch.offset"})).unwrap();
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]);
h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap();
assert_eq!(dialog_curves(&h), vec![first], "{:?}", h.app.status);
h.step(&json!({"click":{"handle":"dialog:Flip"}})).unwrap();
offset_distance(&mut h, "0.5 mm");
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status);
assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4);
assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4, "{:?}", sk.freeform_offsets[1].distance);
assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2));
assert_eq!(solidcraft_engine::sketch::find_profiles(&sk).len(), 3);
let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap();
@@ -61,6 +85,20 @@ fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo()
assert_eq!(h.app.session.doc.to_json(), saved);
}
#[test]
fn offset_window_without_a_curve_reports_it_and_keeps_the_window_open() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
h.settle();
let sid = h.app.session.active_sketch.unwrap();
h.step(&json!({"start":"sketch.offset"})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
let error = h.app.dialog.as_ref().and_then(|d| d.error.clone());
assert_eq!(error.as_deref(), Some("Select the curves to offset first"));
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty());
}
#[test]
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
let mut h = crate::scenario::Harness::new();
+54 -9
View File
@@ -1,18 +1,19 @@
//! Tool windows for sketch-space modify commands, on the `dialogs_surface` pattern (#19): one
//! `Mod` enum behind one `Kind::SketchModify` variant, grown by one variant and one arm per
//! command rather than by a new module each time. The first variant here is Scale, whose
//! toolbar button reached no tool and no dialog at all, so a press ran `sketch.scale` with no
//! parameters and the engine answered "`factor` must be a positive number".
//! `sketch.offset`, `sketch.fillet`, the three `sketch.chamfer.*` commands, the sketch
//! patterns, `sketch.mirror` and `sketch.move` are the same gap and join this module later,
//! each its own change.
//! command rather than by a new module each time. Scale came first, whose toolbar button
//! reached no tool and no dialog at all, so a press ran `sketch.scale` with no parameters and
//! the engine answered "`factor` must be a positive number". Offset joins it here (upstream
//! issue #162 item 1: "Offset does not create a constraint, the size of the offset seems
//! random"). `sketch.fillet`, the three `sketch.chamfer.*` commands, the sketch patterns,
//! `sketch.mirror` and `sketch.move` are the same gap and join this module later, each its own
//! change.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use crate::SolveApp;
use crate::dialogs::{Kind, field, row_label};
use crate::dialogs::{Kind, check, field, row_label};
use crate::selection::{CURVES, POINTS, SelInput};
/// A sketch modify tool window's values.
@@ -22,12 +23,17 @@ pub enum Mod {
/// input; with none picked the command scales about the sketch origin, as its own default
/// already does.
Scale { factor: String },
/// `sketch.offset`: the curves to offset, the distance typed and whether the connected
/// chain follows. A positive distance is left of the chain, as the command defines it;
/// `flip` reverses whatever sign the field holds.
Offset { distance: String, chain: bool, flip: bool },
}
impl Mod {
pub fn title(&self) -> &'static str {
match self {
Mod::Scale { .. } => "SKETCH SCALE",
Mod::Offset { .. } => "OFFSET",
}
}
@@ -35,6 +41,7 @@ impl Mod {
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
Some(match self {
Mod::Scale { factor } => ("Factor", ValueKind::Unitless, factor),
Mod::Offset { distance, .. } => ("Distance", ValueKind::Length, distance),
})
}
}
@@ -42,6 +49,7 @@ impl Mod {
fn inputs(k: &Mod) -> Vec<SelInput> {
match k {
Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Base point", POINTS, false)],
Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)],
}
}
@@ -49,6 +57,7 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id {
"sketch.scale" => Mod::Scale { factor: "2".into() },
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
_ => return None,
};
let i = inputs(&k);
@@ -64,6 +73,17 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
enter |= field(ui, factor);
ui.end_row();
}
Mod::Offset { distance, chain, flip } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
row_label(ui, "Flip");
check(ui, flip);
ui.end_row();
row_label(ui, "Chain");
check(ui, chain);
ui.end_row();
}
}
enter
}
@@ -94,6 +114,11 @@ fn entity_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
.collect()
}
/// Sketch curve ids picked in input `i`.
fn curve_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
items(inputs, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()
}
/// A typed scale factor as the command wants it: the field evaluated against the document's
/// parameters, in the range the command accepts. Checking the range here lets the window say
/// so in its own error line instead of the status bar.
@@ -105,9 +130,21 @@ fn factor(app: &SolveApp, text: &str) -> Result<f64, String> {
Ok(v)
}
/// A typed distance as the command wants it: the field evaluated against the document's
/// parameters, with the sign reversed when Flip is set. A bare number means the design's units,
/// as a typed sketch value does everywhere else.
fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result<f64, String> {
let doc = &app.session.doc;
let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("Distance: {e}"))?;
if !v.is_finite() || v.abs() <= 1e-9 {
return Err("Distance must be a non-zero number".into());
}
Ok(if flip { -v } else { v })
}
/// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for
/// its factor, not an expression string: it has no model parameter to carry one, unlike
/// `sketch.fillet`'s radius.
/// its factor and `sketch.offset` one for its distance, not an expression string: neither has a
/// model parameter to carry one, unlike `sketch.fillet`'s radius.
pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let (cmd, mut params) = match k {
Mod::Scale { factor: text } => {
@@ -122,6 +159,14 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.scale", p)
}
Mod::Offset { distance, chain, flip } => {
let curves = curve_ids(inputs, 0);
if curves.is_empty() {
return Err("Select the curves to offset first".into());
}
let d = signed_length(app, distance, *flip)?;
("sketch.offset", json!({"curves": curves, "distance": d, "chain": chain}))
}
};
if let Value::Object(m) = &mut params {
for (key, value) in extra {
-50
View File
@@ -357,7 +357,6 @@ fn mode(id: &str) -> Option<Mode> {
"sketch.text" => Mode::Points(1),
"sketch.edit_text" => Mode::Entities(1),
"sketch.blend_curve" => Mode::Points(2),
"sketch.offset" => Mode::Entities(1),
"sketch.fillet" | "sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => Mode::Entities(1),
"sketch.constraint.curvature" | "sketch.circle.two_tangent" => Mode::Entities(2),
"sketch.circle.three_tangent" => Mode::Entities(3),
@@ -415,7 +414,6 @@ pub fn hint(id: &str) -> Option<String> {
"sketch.text" => "Text: click where the text starts",
"sketch.edit_text" => "Edit Text: click a letter interior or outline",
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
"sketch.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
"sketch.fillet" => "Fillet: click the corner of two lines",
"sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => {
"Chamfer: click the corner of two lines"
@@ -709,30 +707,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
return;
}
if cmd == "sketch.offset" && !tool.picks.is_empty() {
// Dense derived fit nodes must not snap a nearby side click back
// onto the source, reducing a deliberate offset to zero.
let state = app.session.world_state();
let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| {
let (o, d) = proj.ray(pos);
ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p))
});
if let Some(p) = side {
offset_side(app, tool, p);
}
return;
}
if cmd == "sketch.offset" {
if let Some(sid) = active_sketch(app)
&& let Some(id) = hits.iter().find_map(|hit| match hit {
Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()),
_ => None,
})
{
tool.picks.push(id);
}
return;
}
if cmd == "sketch.edit_text" {
let selection = hits.first().and_then(|hit| match hit {
Hit::SketchCurve { sketch, id, .. } if Some(*sketch) == active_sketch(app) => {
@@ -961,30 +935,6 @@ pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool {
}
}
/// Offset: the second click sets the side and distance.
fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool {
if tool.cmd != "sketch.offset" || tool.picks.is_empty() {
return false;
}
let st = app.session.world_state();
let d = active_sketch(app)
.and_then(|s| st.sketch(s))
.and_then(|ss| {
let curve = ss.sketch.curve_index(&tool.picks[0])?;
if let Some(shape) = ss.sketch.shape(curve) {
Some(shape.dist(p))
} else {
ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp)
}
})
.unwrap_or(0.0);
if d > 1e-6 {
let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)}));
}
tool.picks.clear();
true
}
/// Rubber band for point tools and pick markers.
pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
let Some(sid) = active_sketch(app) else { return };
@@ -0,0 +1,82 @@
[
{"note": "#162 item 1 (\"the size of the offset seems random\"): sketch.offset gets a tool window carrying Curves, Distance, Flip and Chain, instead of a bare click-pick that measured the distance off the second click."},
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"expect": {"sketching": true}},
{"start": "sketch.circle.center"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [10, 0]}},
{"key": "Escape"},
{"expect": {"sketch_curves": {"own": 1}, "errors": 0}},
{"note": "Starting the command opens the window and commits nothing."},
{"start": "sketch.offset"},
{"expect": {"dialog": "Offset", "sketch_curves": {"own": 1}, "errors": 0}},
{"click": {"sketch": [10, 0]}},
{"expect": {"dialog": "Offset", "sketch_curves": {"own": 1}, "errors": 0}},
{"note": "The typed number reaches the command unchanged: a 10 mm circle offset 3 mm is a 7 mm circle."},
{"click": {"handle": "dialog:Distance"}},
{"key": "A", "cmd": true},
{"text": "3"},
{"key": "Enter"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 2}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "radius"], "value": 7}}},
{"note": "Flip reverses the side: the same 3 mm grows the circle to 13 mm."},
{"start": "sketch.offset"},
{"click": {"sketch": [10, 0]}},
{"click": {"handle": "dialog:Flip"}},
{"click": {"handle": "dialog:Distance"}},
{"key": "A", "cmd": true},
{"text": "3"},
{"key": "Enter"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 3}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 2, "radius"], "value": 13}}},
{"shot": "sketch_offset_circle"},
{"start": "sketch.finish"},
{"note": "Chain on (the default): one picked edge of a rectangle offsets the whole connected chain, four curves. The inner rectangle is 34 by 24, so the offset came from the typed 3 mm on every side."},
{"start": "sketch.create"},
{"click": {"plane": "XZ"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"key": "Escape"},
{"expect": {"sketch_curves": {"own": 4}, "errors": 0}},
{"start": "sketch.offset"},
{"click": {"sketch": [20, 0]}},
{"expect": {"dialog": "Offset", "sketch_curves": {"own": 4}}},
{"click": {"handle": "dialog:Distance"}},
{"key": "A", "cmd": true},
{"text": "3"},
{"key": "Enter"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}},
{"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 1, "area_mm2"], "value": 816}}},
{"shot": "sketch_offset_chain"},
{"start": "sketch.finish"},
{"note": "Chain cleared: the same pick offsets that one curve, so the sketch gains one."},
{"start": "sketch.create"},
{"click": {"plane": "YZ"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"key": "Escape"},
{"expect": {"sketch_curves": {"own": 4}, "errors": 0}},
{"start": "sketch.offset"},
{"click": {"sketch": [20, 0]}},
{"click": {"handle": "dialog:Chain"}},
{"click": {"handle": "dialog:Distance"}},
{"key": "A", "cmd": true},
{"text": "3"},
{"key": "Enter"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}},
{"note": "Escape closes the window and leaves the sketch exactly as it was."},
{"start": "sketch.offset"},
{"click": {"sketch": [20, 0]}},
{"expect": {"dialog": "Offset"}},
{"key": "Escape"},
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}}
]