A tool window for the circular sketch pattern (#801)
Circular sketch pattern gets a tool window with entities, center, quantity and total angle, replacing the click flow.
This commit is contained in:
1 parent
e99b3bd4e8
commit
152418b624
8 files changed
+206
-26
No files matched your search
@@ -311,6 +311,7 @@
|
||||
"ui.to_object": "Bis Objekt",
|
||||
"ui.top": "Oben",
|
||||
"ui.torus": "Torus",
|
||||
"ui.total_angle": "Gesamtwinkel",
|
||||
"ui.trim": "Kürzen",
|
||||
"ui.two_sides": "Beidseitig",
|
||||
"ui.ui_scale": "UI-Skalierung",
|
||||
|
||||
@@ -599,6 +599,7 @@
|
||||
"ui.to_object": "To Object",
|
||||
"ui.top": "Top",
|
||||
"ui.torus": "Torus",
|
||||
"ui.total_angle": "Total angle",
|
||||
"ui.trackpad_pan_tip": "Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
|
||||
"ui.trim": "Trim",
|
||||
"ui.two_finger_scroll_pans": "Two-finger scroll pans",
|
||||
|
||||
@@ -599,6 +599,7 @@
|
||||
"ui.to_object": "オブジェクトまで",
|
||||
"ui.top": "上面",
|
||||
"ui.torus": "トーラス",
|
||||
"ui.total_angle": "合計角度",
|
||||
"ui.trackpad_pan_tip": "トラックパッドのジェスチャーでパンします(オフの場合はマウスホイールと同様にズームします)。マウスホイールはズーム、サイドホイールは横方向のパン、ピンチ操作は常にズームになります。",
|
||||
"ui.trim": "トリム",
|
||||
"ui.two_finger_scroll_pans": "2本指スクロールでパン",
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
"ui.to_object": "객체까지",
|
||||
"ui.top": "상면",
|
||||
"ui.torus": "토러스",
|
||||
"ui.total_angle": "전진 각도",
|
||||
"ui.trim": "트림",
|
||||
"ui.two_sides": "양방향",
|
||||
"ui.ui_scale": "UI 배율",
|
||||
|
||||
@@ -78,6 +78,9 @@ pub enum Mod {
|
||||
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
|
||||
/// apart, then the same along the perpendicular direction the command derives from it.
|
||||
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
|
||||
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
|
||||
/// angle they span.
|
||||
PatternCirc { count: String, angle: String },
|
||||
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
|
||||
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
|
||||
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
|
||||
@@ -95,6 +98,7 @@ impl Mod {
|
||||
Mod::Mirror => "MIRROR",
|
||||
Mod::Move { .. } => "MOVE/COPY",
|
||||
Mod::PatternRect { .. } => "RECTANGULAR PATTERN",
|
||||
Mod::PatternCirc { .. } => "CIRCULAR PATTERN",
|
||||
Mod::Corner { chamfer: false, .. } => "FILLET",
|
||||
Mod::Corner { .. } => "CHAMFER",
|
||||
}
|
||||
@@ -108,6 +112,7 @@ impl Mod {
|
||||
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
|
||||
Mod::Mirror => None,
|
||||
Mod::PatternRect { spacing, .. } => Some(("Spacing", ValueKind::Length, spacing)),
|
||||
Mod::PatternCirc { angle, .. } => Some(("Angle", ValueKind::Angle, angle)),
|
||||
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
|
||||
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
|
||||
}
|
||||
@@ -124,6 +129,8 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
|
||||
// default, the way the engine reads it.
|
||||
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Direction", AXES, false)],
|
||||
// The centre is required: `sketch.pattern.circular` has no default for it.
|
||||
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
// cuts one corner, so its input holds one.
|
||||
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)],
|
||||
@@ -147,6 +154,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"sketch.mirror" => Mod::Mirror,
|
||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
"sketch.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||||
"sketch.fillet" => corner(false, 0),
|
||||
"sketch.chamfer.equal_distance" => corner(true, 0),
|
||||
"sketch.chamfer.two_distance" => corner(true, 1),
|
||||
@@ -211,6 +219,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
||||
if *chamfer {
|
||||
row_label(ui, "Chamfer Type");
|
||||
@@ -267,6 +282,9 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
|
||||
};
|
||||
Some(plane.dir_to_world(dir))
|
||||
}
|
||||
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
|
||||
// handle only for Revolve, through machinery tied to its own extents, so the circular
|
||||
// pattern takes the value box alone until that manipulator is made general.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -441,6 +459,23 @@ fn corner_ref(app: &SolveApp, point: &str, flip: bool) -> Result<Value, String>
|
||||
Ok(json!({"a": id(b)?, "b": id(a)?}))
|
||||
}
|
||||
|
||||
/// A typed total angle as the command wants it, in degrees. A bare number means degrees, as a
|
||||
/// typed angle does everywhere else. `sketch.pattern.circular` takes the full turn as 360 and
|
||||
/// refuses anything outside that range.
|
||||
fn total_angle(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
|
||||
let v = app.session.doc.eval(text.trim(), ValueKind::Angle).map_err(|e| format!("{label}: {e}"))?.to_degrees();
|
||||
if !v.is_finite() || v.abs() <= 1e-9 || v.abs() > 360.0 {
|
||||
return Err(format!("{label} must be more than 0 and at most 360 degrees"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The bare sketch point id of a single point pick, as a command's `center` or `base` reads it.
|
||||
/// `point_id` above takes one `Sel`; this one takes the first pick of an input.
|
||||
fn picked_point(inputs: &[SelInput], i: usize) -> Option<String> {
|
||||
items(inputs, i).first().and_then(point_id).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for
|
||||
/// its factor, `sketch.offset` one for its distance and `sketch.move` its distances and angle,
|
||||
/// not expression strings: none has a model parameter to carry one, unlike `sketch.fillet`'s radius.
|
||||
@@ -537,6 +572,19 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
}
|
||||
("sketch.pattern.rectangular", p)
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
let entities = entity_ids(inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
}
|
||||
let center = picked_point(inputs, 1).ok_or("Select the centre the pattern turns around first")?;
|
||||
let n = whole(app, "Quantity", count)?;
|
||||
if n < 2 {
|
||||
return Err("Quantity must be at least 2".into());
|
||||
}
|
||||
let a = total_angle(app, "Total angle", angle)?;
|
||||
("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a}))
|
||||
}
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
if corners.is_empty() {
|
||||
|
||||
@@ -519,3 +519,96 @@ fn the_spacing_gets_a_canvas_arrow_along_the_pattern_direction() {
|
||||
let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve");
|
||||
assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::Y));
|
||||
}
|
||||
|
||||
/// A sketch holding one circle 30 mm out and a lone point at the origin, with the circular
|
||||
/// pattern window open on it and both inputs filled. The point is the centre the pattern turns
|
||||
/// around; the command has no default for it. Returns the centre's bare point id.
|
||||
fn open_circ() -> (crate::scenario::Harness, String) {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point": [0, 0]})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center": [30, 0], "radius": 10})).unwrap();
|
||||
h.settle();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let (curve, centre) = {
|
||||
let st = h.app.session.model.state();
|
||||
let sk = &st.sketch(sid).unwrap().sketch;
|
||||
let curve = sk.curves[0].id.clone();
|
||||
let centre = sk.points.iter().find(|p| p.pos.len() < 1e-9).unwrap().id.clone();
|
||||
(curve, centre)
|
||||
};
|
||||
h.step(&json!({"start": "sketch.pattern.circular"})).unwrap();
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id: curve }];
|
||||
d.inputs[1].items = vec![Sel::SketchPoint { id: format!("{sid}:{centre}") }];
|
||||
(h, centre)
|
||||
}
|
||||
|
||||
fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind {
|
||||
*c = count.into();
|
||||
*a = angle.into();
|
||||
} else {
|
||||
panic!("not the circular pattern window: {:?}", d.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_pattern_window_opens_instead_of_a_click_tool() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start": "sketch.pattern.circular"})).unwrap();
|
||||
assert!(h.app.tool.is_none(), "the click tool still takes the command");
|
||||
let d = h.app.dialog.as_ref().expect("the window opened");
|
||||
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Center"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_typed_count_and_total_angle_reach_the_command() {
|
||||
let (mut h, centre) = open_circ();
|
||||
circ(&mut h, "4", "180");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].0, "sketch.pattern.circular");
|
||||
// The click flow could only ever send a count of 6, and never any angle.
|
||||
assert_eq!(cmds[0].1["count"], json!(4));
|
||||
assert_eq!(cmds[0].1["angle"], json!(180.0));
|
||||
assert_eq!(cmds[0].1["center"], json!(centre), "the bare point id the command resolves");
|
||||
circ(&mut h, "4", "90 deg");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0));
|
||||
}
|
||||
|
||||
/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular`
|
||||
/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated
|
||||
/// here or it disappears in silence.
|
||||
#[test]
|
||||
fn an_angle_expression_is_evaluated_rather_than_sent_as_a_string() {
|
||||
let (mut h, _) = open_circ();
|
||||
h.app.run("parameters.add", json!({"name": "sweep", "expression": "120", "unit": "deg"})).unwrap();
|
||||
circ(&mut h, "3", "sweep");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let angle = apply_commands(&h.app, &d).unwrap()[0].1["angle"].as_f64().expect("a number, not the text `sweep`");
|
||||
assert!((angle - 120.0).abs() < 1e-9, "{angle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_centre_a_short_count_and_a_bad_angle_are_all_refused() {
|
||||
let (mut h, _) = open_circ();
|
||||
for bad in ["0", "400 deg", "wat"] {
|
||||
circ(&mut h, "4", bad);
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).unwrap_err().contains("Total angle"), "angle {bad} was accepted");
|
||||
}
|
||||
circ(&mut h, "1", "360 deg");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).unwrap_err().contains("Quantity"));
|
||||
circ(&mut h, "4", "360 deg");
|
||||
h.app.dialog.as_mut().unwrap().inputs[1].items.clear();
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).unwrap_err().contains("centre"));
|
||||
}
|
||||
@@ -335,8 +335,6 @@ enum Mode {
|
||||
Points(usize),
|
||||
/// Pick sketch entities; `n` run the command (0: until right-click).
|
||||
Entities(usize),
|
||||
/// Pick entities until right-click, then one more pick or point (mirror line, centre).
|
||||
EntitiesThen,
|
||||
/// Model faces / sketch curves in steps (see the command).
|
||||
Steps,
|
||||
}
|
||||
@@ -359,7 +357,6 @@ fn mode(id: &str) -> Option<Mode> {
|
||||
"sketch.circle.three_tangent" => Mode::Entities(3),
|
||||
"sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0),
|
||||
"sketch.centerline" => Mode::Entities(1),
|
||||
"sketch.pattern.circular" => Mode::EntitiesThen,
|
||||
"sketch.project_to_surface" | "sketch.intersection_curve" | "sketch.spun_profile" => Mode::Steps,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -417,7 +414,6 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.constrainer" => "Constrain: click one or two entities; right-click to apply",
|
||||
"sketch.constraint.polygon" => "Polygon: click the lines of a closed chain; right-click to apply",
|
||||
"sketch.centerline" => "Centerline: click lines",
|
||||
"sketch.pattern.circular" => "Circular pattern: click entities, right-click, then click the centre",
|
||||
"sketch.project_to_surface" => "Project to surface: click curves of other sketches, right-click, then click the face",
|
||||
"sketch.intersection_curve" => "Intersection curve: click two faces",
|
||||
"sketch.spun_profile" => "Spun profile: click the body, then the axis line",
|
||||
@@ -714,27 +710,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
Mode::EntitiesThen => {
|
||||
// Stage 2 (after a right-click) is marked by a "|" pick.
|
||||
if tool.picks.last().is_some_and(|x| x == "|") {
|
||||
let ents: Vec<String> = tool.picks.iter().filter(|x| *x != "|").cloned().collect();
|
||||
// `sketch.pattern.rectangular` left this flow for a tool window: its count was
|
||||
// hard-coded to 3 here and the command's second count and spacing could not be
|
||||
// reached at all. The circular pattern is the last command still on it.
|
||||
if cmd == "sketch.pattern.circular"
|
||||
&& let Some(p) = at
|
||||
{
|
||||
let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((id, _)) = sketch_entity(app, &hits)
|
||||
&& !tool.picks.contains(&id)
|
||||
{
|
||||
tool.picks.push(id);
|
||||
}
|
||||
}
|
||||
Mode::Steps => match cmd {
|
||||
"sketch.intersection_curve" => {
|
||||
if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) {
|
||||
@@ -865,7 +840,7 @@ pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool {
|
||||
tool.pts.clear();
|
||||
true
|
||||
}
|
||||
Mode::EntitiesThen | Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => {
|
||||
Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => {
|
||||
tool.picks.push("|".into());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
[
|
||||
{"note": "sketch.pattern.circular gets a tool window carrying Entities, Center, Quantity and Total angle. Its click flow sent a count hard-coded to 6 and never passed an angle at all."},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.point", "params": {"point": [0, 0]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [30, 0], "radius": 10}}},
|
||||
{"call": "ui.view", "params": {"view": "top"}},
|
||||
{"expect": {"sketching": true, "sketch_curves": {"own": 1}, "errors": 0}},
|
||||
|
||||
{"note": "Starting the command opens the window and commits nothing. Picking the circle and the centre point still commits nothing."},
|
||||
{"start": "sketch.pattern.circular"},
|
||||
{"expect": {"dialog": "PatternCirc", "sketch_curves": {"own": 1}, "errors": 0}},
|
||||
{"click": {"sketch": [40, 0]}},
|
||||
{"click": {"handle": "dialog:Center"}},
|
||||
{"click": {"sketch": [0, 0]}},
|
||||
{"expect": {"dialog": "PatternCirc", "sketch_curves": {"own": 1}, "errors": 0}},
|
||||
|
||||
{"note": "A count of 4 over a total angle of 180 degrees: the old flow could only send 6 over the full turn. Four instances 60 degrees apart put the last one straight across the centre."},
|
||||
{"click": {"handle": "dialog:Quantity"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "4"},
|
||||
{"click": {"handle": "dialog:Total angle"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "180"},
|
||||
{"click": {"handle": "dialog:OK"}},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 4}}},
|
||||
{"note": "60 degrees around: 30 mm at 60 degrees is (15, 25.980762)."},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "center_at", 0], "value": 15}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "center_at", 1], "value": 25.980762113533157}}},
|
||||
{"note": "180 degrees around: straight across the centre. The angle was applied, not ignored."},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "center_at", 0], "value": -30}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "center_at", 1], "value": 0}}},
|
||||
{"shot": "sketch_pattern_circ_half_turn"},
|
||||
|
||||
{"note": "A full turn with a count that is not 6 either: four instances 90 degrees apart around a second centre. The half turn above reaches x = -30, and the second circle reaches y = 90, so refit before picking anything up there."},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.point", "params": {"point": [0, 60]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [0, 80], "radius": 10}}},
|
||||
{"call": "ui.view", "params": {"view": "fit"}},
|
||||
{"expect": {"sketch_curves": {"own": 5}, "errors": 0}},
|
||||
{"start": "sketch.pattern.circular"},
|
||||
{"click": {"sketch": [10, 80]}},
|
||||
{"click": {"handle": "dialog:Center"}},
|
||||
{"click": {"sketch": [0, 60]}},
|
||||
{"click": {"handle": "dialog:Quantity"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "4"},
|
||||
{"click": {"handle": "dialog:OK"}},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "center_at", 0], "value": -20}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "center_at", 1], "value": 60}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 7, "center_at", 0], "value": 20}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 7, "center_at", 1], "value": 60}}},
|
||||
{"shot": "sketch_pattern_circ_full_turn"},
|
||||
|
||||
{"note": "Escape closes the window and leaves the sketch and its undo history exactly as they were."},
|
||||
{"start": "sketch.pattern.circular"},
|
||||
{"click": {"sketch": [10, 80]}},
|
||||
{"expect": {"dialog": "PatternCirc"}},
|
||||
{"key": "Escape"},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}
|
||||
]
|
||||
Reference in new issue
Block a user