Guide sketch patterns with live previews and editable offsets (#966)

Sketch Mirror, rectangular and circular Pattern, and Offset now show a green live preview that follows the selection and settings; OK commits, Cancel leaves the sketch unchanged. The shared dialogs show the current selection step and source counts, sources can be toggled with Ctrl/Cmd-click, cleared and reselected.

Offsets keep editable driving distance dimensions (One Side, Two Sides, Symmetric) that update the derived curves without moving the source, with undo/redo and save/reopen. Format 19: older files keep their fixed offsets.

New dialog labels in en, zh, ja, de and ko.

Fixes #696. Fixes #517.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-14 01:58:01 -04:00
1 parent 9c51f9d433
commit 47a7f29485
29 files changed
+1107 -186

No files matched your search

+3
View File
@@ -9,6 +9,9 @@
"dialog.edit_title": "{title} bearbeiten",
"dialog.select": "↖ Auswählen",
"dialog.selected_count": "↖ {count} ausgewählt",
"dialog.step": "Schritt",
"dialog.selection_step": "{step} von {total}: {input} auswählen",
"dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.",
"document.save_changes": "Änderungen in {name} speichern?",
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
+3
View File
@@ -279,6 +279,9 @@
"dialog.edit_title": "EDIT {title}",
"dialog.select": "↖ Select",
"dialog.selected_count": "↖ {count} selected",
"dialog.step": "Step",
"dialog.selection_step": "{step} of {total}: Select {input}",
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
"document.save_changes": "Save changes to {name}?",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
+3
View File
@@ -279,6 +279,9 @@
"dialog.edit_title": "{title}を編集",
"dialog.select": "↖ 選択",
"dialog.selected_count": "↖ {count} 個選択中",
"dialog.step": "ステップ",
"dialog.selection_step": "{total} ステップ中 {step}: {input} を選択",
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
+3
View File
@@ -9,6 +9,9 @@
"dialog.edit_title": "{title} 편집",
"dialog.select": "↖ 선택",
"dialog.selected_count": "↖ {count}개 선택됨",
"dialog.step": "단계",
"dialog.selection_step": "{step}/{total} 단계: {input} 선택",
"dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.",
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
"document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
+3
View File
@@ -279,6 +279,9 @@
"dialog.edit_title": "编辑 {title}",
"dialog.select": "↖ 选择",
"dialog.selected_count": "↖ 已选择 {count} 个",
"dialog.step": "步骤",
"dialog.selection_step": "第 {step} 步,共 {total} 步:选择{input}",
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
"document.save_changes": "保存对 {name} 的更改?",
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
+1
View File
@@ -44,6 +44,7 @@ impl Tokens {
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
preview_add: Color32::from_rgb(112, 160, 226),
preview_sketch: Color32::from_rgb(32, 190, 96),
preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110),
cube_face: Color32::from_rgb(236, 239, 244),
cube_border: Color32::from_rgb(85, 94, 110),
+6 -2
View File
@@ -130,12 +130,15 @@
//! plate or a face of another body at the same height) is mirrored into that face when the
//! design is evaluated. Holes whose direction enters their face build as before.
//! - 19: editable associative offset-distance dimensions. Older offsets retain their saved
//! fixed distances; migration does not invent dimensions or change geometry.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 18;
pub const FORMAT: u32 = 19;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -152,7 +155,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 17] = [
const JSON_STEPS: [JsonStep; 18] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -170,6 +173,7 @@ const JSON_STEPS: [JsonStep; 17] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+7
View File
@@ -2204,6 +2204,13 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "break the offset association before deleting only part of its curved boundary"));
}
}
if sk.constraints.iter().any(|c| cons.contains(&c.id) && matches!(c.kind, ConstraintKind::OffsetDistance { .. })) {
let report = solve_current(sk, doc)?;
if !report.keeps_orientation() {
return Err(bad(cmd, "cannot remove an offset dimension from a conflicting sketch"));
}
sk.refresh_offsets()?;
}
sk.constraints.retain(|c| !cons.contains(&c.id));
let pids: Vec<String> = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect();
sk.remove_curves(&curves);
@@ -430,6 +430,7 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
};
let pt = |p: usize| sk.point(p);
match *k {
OffsetDistance { ref output, .. } => sk.offset_dimension_points(output).map(|(a, b)| (a + b) * 0.5),
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
PointOnCurve { p, .. } => pt(p),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
+216 -109
View File
@@ -43,9 +43,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("O")
.enabled(in_sketch)
.params(concat!(
"curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ",
"curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ",
"direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way, as for `solid.extrude`; no `side`, whose job it takes over), ",
"distance2?: positive number (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`",
"distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions",
created_doc!()
)),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
@@ -931,6 +931,7 @@ struct Side {
curves: Vec<String>,
constraints: Vec<String>,
association: Option<String>,
param: Option<String>,
}
/// The signed distances an offset runs at. One side is the distance as given. `symmetric` is the
@@ -967,7 +968,7 @@ fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(
cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?);
}
}
return Ok(Side { distance: d, curves: created.curves, constraints: cons, association: Some(created.association) });
return Ok(Side { distance: d, curves: created.curves, constraints: cons, association: Some(created.association), param: None });
}
let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
@@ -1063,16 +1064,142 @@ fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(
sk.offsets.push(solidcraft_sketch::OffsetChain { sources: sources.to_vec(), outputs: outputs.clone(), distance: d, closed, topology: None });
sk.refresh_offsets()?;
}
Ok(Side { distance: d, curves: outputs, constraints: cons, association: None })
Ok(Side { distance: d, curves: outputs, constraints: cons, association: None, param: None })
}
/// Numbers remain millimetres for programmatic callers; expressions use design units.
fn offset_length(s: &Session, p: &Value, key: &str, positive: bool) -> Result<(f64, String)> {
let cmd = "sketch.offset";
let expression = match num(p, key) {
Some(v) => format!("{v} mm"),
None => expr(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a length expression")))?,
};
let expression = s.doc.with_design_unit(&expression, solidcraft_doc::expr::Kind::Length);
let value = s.doc.eval(&expression, solidcraft_doc::expr::Kind::Length)?;
if !value.is_finite() || value.abs() <= 1e-9 || value.abs() > solidcraft_sketch::MAX_COORD || (positive && value < 0.0) {
return Err(bad(cmd, format!("`{key}` must be a {}length within the sketch bounds", if positive { "positive " } else { "finite nonzero " })));
}
Ok((value, expression))
}
#[derive(Clone, Copy)]
struct OffsetOptions {
side: Option<Vec2>,
chain: bool,
distance: f64,
symmetric: bool,
distance2: Option<f64>,
}
/// Build every side of one connected source group. The pool is frozen before any output
/// is created, so later groups cannot accidentally walk a newly created offset.
fn offset_group(sk: &mut Sketch, ids: &[String], eligible: &std::collections::HashSet<String>, options: OffsetOptions) -> Result<Vec<Side>> {
let OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 } = options;
let cmd = "sketch.offset";
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<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| {
topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output))
}) {
return Err(bad(
cmd,
"offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology",
));
}
let mut sides: Vec<Side> = Vec::new();
// Circles offset on their own, concentrically. Both sides are then a smaller circle and
// a larger one; the one toward the centre still has to have a size.
if let Some(CurveKind::Circle { c, r }) = sk.curves.get(first).map(|c| c.kind.clone()) {
let cc = sk.point(c).unwrap_or_default();
let source = sk.curves.get(first).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "offset source circle vanished"))?;
let grow = match side {
Some(q) => q.dist(cc) > r,
None => d0 < 0.0,
};
let radii: Vec<f64> = match (symmetric, distance2) {
(true, _) => vec![r - d0.abs(), r + d0.abs()],
(_, Some(second)) if grow => vec![r + d0.abs(), r - second],
(_, Some(second)) => vec![r - d0.abs(), r + second],
_ if grow => vec![r + d0.abs()],
_ => vec![r - d0.abs()],
};
for nr in radii {
if nr <= 1e-9 {
return Err(bad(cmd, "the offset circle would have no size"));
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
let outputs = ids_of(sk, &[ci]);
sk.offsets.push(solidcraft_sketch::OffsetChain {
sources: vec![(source.clone(), true)],
outputs: outputs.clone(),
distance: r - nr,
closed: true,
topology: None,
});
sides.push(Side { distance: r - nr, curves: outputs, constraints: Vec::new(), association: None, param: None });
}
return Ok(sides);
}
// The chain is walked once, before anything is created: a second side has to follow the
// same source curves, not a pool the first side has already added its output to.
let pool: Vec<usize> = if use_chain {
(0..sk.curves.len())
.filter(|i| {
sk.curves.get(*i).is_some_and(|c| eligible.contains(&c.id) && c.construction == sk.curves.get(first).is_some_and(|f| f.construction))
})
.collect()
} else {
sel.clone()
};
let (order, closed) = chain(sk, first, &pool);
// Side: toward `side` (compare both offsets' distance to it), else the sign.
let d = match side {
Some(q) => {
let dist = |dd: f64| {
offset_piece(sk, first, order.iter().find(|x| x.0 == first).is_none_or(|x| x.1), dd).map(|pc| piece_dist(&pc, q)).unwrap_or(f64::MAX)
};
if dist(d0.abs()) <= dist(-d0.abs()) { d0.abs() } else { -d0.abs() }
}
None => d0,
};
let runs = offset_runs(d, symmetric, distance2);
if order.iter().any(|(i, _)| sk.shape(*i).is_none()) {
// A smooth free-form entity is offset from exact pieces. Junctions that
// would require trimming/intersecting independent normal offsets are
// refused rather than joined by a guessed average point.
if order.len() != 1 {
return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous"));
}
let (source, forward) = *order.first().ok_or_else(|| bad(cmd, "no curves"))?;
for dd in runs {
let output = sk.add_freeform_offset(source, forward, dd)?;
sides.push(Side { distance: dd, curves: ids_of(sk, &[output]), constraints: Vec::new(), association: None, param: None });
}
return Ok(sides);
}
let sources = order
.iter()
.map(|(index, forward)| {
sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished"))
})
.collect::<Result<Vec<_>>>()?;
let convex = closed && sk.convex_offset_supported(&sources)?;
for dd in runs {
sides.push(offset_side(sk, cmd, &order, &sources, closed, convex, dd)?);
}
Ok(sides)
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list curves"));
if ids.is_empty() || ids.len() > 500 {
return Err(bad(cmd, "`curves` must list 1…500 curves"));
}
let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?;
let (value, expression) = offset_length(s, p, "distance", false)?;
let expression = if value > 0.0 { expression } else { format!("-({expression})") };
let d0 = if bool_(p, "flip").unwrap_or(false) { -value } else { value };
let side = p.get("side").and_then(vec2);
let use_chain = bool_(p, "chain").unwrap_or(true);
// Both sides at once. The words keep the meaning `solid.extrude` gives them: `symmetric` is
@@ -1085,134 +1212,71 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
Some(other) => return Err(bad(cmd, format!("unknown direction `{other}` (one_side, symmetric)"))),
};
let distance2 = match p.get("distance2") {
None => None,
Some(_) => Some(num(p, "distance2").filter(|d| *d > 1e-9).ok_or_else(|| {
bad(
cmd,
"`distance2` must be a positive number: the other side's distance (the sign of `distance`, or `side`, picks which side is which)",
)
})?),
};
let second = if p.get("distance2").is_some() { Some(offset_length(s, p, "distance2", true)?) } else { None };
let distance2 = second.as_ref().map(|(value, _)| *value);
let expression2 = second.map(|(_, expression)| expression);
if symmetric && distance2.is_some() {
return Err(bad(cmd, "give `distance2` or `symmetric`, not both"));
}
if symmetric && side.is_some() {
return Err(bad(cmd, "`symmetric` offsets both sides, so `side` has no side left to pick: drop one of them"));
}
let (sides, info) = edit(s, p, cmd, true, |sk, doc| {
let ((sides, param, param2), info) = edit(s, p, cmd, true, |sk, doc| {
sk.refresh_offsets()?;
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<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| {
topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output))
}) {
return Err(bad(
cmd,
"offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology",
));
}
let mut sides: Vec<Side> = Vec::new();
// Circles offset on their own, concentrically. Both sides are then a smaller circle and
// a larger one; the one toward the centre still has to have a size.
if let Some(CurveKind::Circle { c, r }) = sk.curves.get(first).map(|c| c.kind.clone()) {
let cc = sk.point(c).unwrap_or_default();
let source = sk.curves.get(first).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "offset source circle vanished"))?;
let grow = match side {
Some(q) => q.dist(cc) > r,
None => d0 < 0.0,
};
let radii: Vec<f64> = match (symmetric, distance2) {
(true, _) => vec![r - d0.abs(), r + d0.abs()],
(_, Some(second)) if grow => vec![r + d0.abs(), r - second],
(_, Some(second)) => vec![r - d0.abs(), r + second],
_ if grow => vec![r + d0.abs()],
_ => vec![r - d0.abs()],
};
for nr in radii {
if nr <= 1e-9 {
return Err(bad(cmd, "the offset circle would have no size"));
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
let outputs = ids_of(sk, &[ci]);
sk.offsets.push(solidcraft_sketch::OffsetChain {
sources: vec![(source.clone(), true)],
outputs: outputs.clone(),
distance: r - nr,
closed: true,
topology: None,
});
sides.push(Side { distance: r - nr, curves: outputs, constraints: Vec::new(), association: None });
let eligible = sk.curves.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>();
let param = doc.new_model_param(&expression, "mm");
let param2 = expression2.as_ref().map(|e| doc.new_model_param(e, "mm"));
let mut pending = ids.clone();
let mut sides = Vec::new();
while !pending.is_empty() {
let mut group = offset_group(sk, &pending, &eligible, OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 })?;
let output = group.first().and_then(|side| side.curves.first()).ok_or_else(|| bad(cmd, "offset produced no curves"))?;
let sources = sk
.offsets
.iter()
.find(|o| o.owns_output(output))
.map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>())
.or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()]))
.ok_or_else(|| bad(cmd, "offset association is missing"))?;
for (index, side) in group.iter_mut().enumerate() {
let name = if index == 1 { param2.as_ref().unwrap_or(&param) } else { &param };
let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?;
side.constraints.push(sk.add_constraint(
ConstraintKind::OffsetDistance { output: output.clone(), value: side.distance.abs(), reverse: side.distance < 0.0 },
Some(name.clone()),
)?);
side.param = Some(name.clone());
}
return Ok(sides);
}
// The chain is walked once, before anything is created: a second side has to follow the
// same source curves, not a pool the first side has already added its output to.
let pool: Vec<usize> = if use_chain {
(0..sk.curves.len())
.filter(|i| sk.curves.get(*i).is_some_and(|c| c.construction == sk.curves.get(first).is_some_and(|f| f.construction)))
.collect()
} else {
sel.clone()
};
let (order, closed) = chain(sk, first, &pool);
// Side: toward `side` (compare both offsets' distance to it), else the sign.
let d = match side {
Some(q) => {
let dist = |dd: f64| {
offset_piece(sk, first, order.iter().find(|x| x.0 == first).is_none_or(|x| x.1), dd)
.map(|pc| piece_dist(&pc, q))
.unwrap_or(f64::MAX)
};
if dist(d0.abs()) <= dist(-d0.abs()) { d0.abs() } else { -d0.abs() }
let before = pending.len();
pending.retain(|id| !sources.contains(id));
if pending.len() == before {
return Err(bad(cmd, "offset did not consume its source selection"));
}
None => d0,
};
let runs = offset_runs(d, symmetric, distance2);
if order.iter().any(|(i, _)| sk.shape(*i).is_none()) {
// A smooth free-form entity is offset from exact pieces. Junctions that
// would require trimming/intersecting independent normal offsets are
// refused rather than joined by a guessed average point.
if order.len() != 1 {
return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous"));
}
let (source, forward) = *order.first().ok_or_else(|| bad(cmd, "no curves"))?;
for dd in runs {
let output = sk.add_freeform_offset(source, forward, dd)?;
sides.push(Side { distance: dd, curves: ids_of(sk, &[output]), constraints: Vec::new(), association: None });
}
return Ok(sides);
sides.extend(group);
}
let sources = order
.iter()
.map(|(index, forward)| {
sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished"))
})
.collect::<Result<Vec<_>>>()?;
let convex = closed && sk.convex_offset_supported(&sources)?;
for dd in runs {
sides.push(offset_side(sk, cmd, &order, &sources, closed, convex, dd)?);
}
Ok(sides)
Ok((sides, param, param2))
})?;
let curves: Vec<&String> = sides.iter().flat_map(|side| side.curves.iter()).collect();
let constraints: Vec<&String> = sides.iter().flat_map(|side| side.constraints.iter()).collect();
let each: Vec<Value> = sides
.iter()
.map(|side| {
let mut entry = json!({"distance": side.distance, "curves": side.curves});
let mut entry = json!({"distance": side.distance, "curves": side.curves, "param": side.param});
if let Some(association) = &side.association {
entry["association"] = json!(association);
}
entry
})
.collect();
let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each});
let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param});
if let Some(param2) = param2 {
response["param2"] = json!(param2);
}
// One side keeps the single `association` it always returned. Two sides have one each, in
// `sides`, so there is no single association left to name.
if let [only] = sides.as_slice()
@@ -1234,7 +1298,8 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.filter_map(|record| record.topology.as_ref())
.any(|record| record.id == output || record.roles.iter().any(|role| role.output == output));
if record.is_none() && !topology {
let analytic = sk.offsets.iter().any(|o| o.outputs.iter().any(|id| id == output));
if record.is_none() && !topology && !analytic {
return Err(bad(cmd, "curve is not an offset output"));
}
// A parameter edit may have changed the evaluated source without
@@ -1257,7 +1322,48 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(curves) = sk.break_offset_topology(output)? {
return Ok((output.to_string(), Some(curves)));
}
let ordinary = sk.offsets.iter().position(|o| o.topology.is_none() && o.outputs.iter().any(|id| id == output));
if let Some(index) = ordinary {
let record = sk.offsets.remove(index);
let centres = record
.outputs
.iter()
.filter_map(|id| sk.curve_index(id).and_then(|i| sk.curves.get(i)))
.filter_map(|curve| match curve.kind {
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c),
_ => None,
})
.collect::<std::collections::BTreeSet<_>>();
for centre in centres {
let position = sk.point(centre).ok_or_else(|| bad(cmd, "missing offset centre"))?;
let detached = sk.add_point(position, None)?;
for curve in &mut sk.curves {
if record.outputs.contains(&curve.id) {
match &mut curve.kind {
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } if *c == centre => *c = detached,
_ => {}
}
}
}
}
sk.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output, .. } if record.outputs.contains(output)));
for id in &record.outputs {
if let Some(curve) = sk.curve_index(id).and_then(|i| sk.curves.get(i)) {
let points = curve.kind.point_ids();
for i in points {
if i != 0
&& sk.curves.iter().all(|c| !c.kind.uses(i) || record.outputs.contains(&c.id))
&& let Some(p) = sk.points.get_mut(i)
{
p.fixed = false;
}
}
}
}
return Ok((output.to_string(), Some(record.outputs)));
}
let record = record.ok_or_else(|| bad(cmd, "offset association changed while materializing its current geometry"))?;
sk.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output: id, .. } if id == output));
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
let indices = sk.curves[ci].kind.point_ids();
sk.freeform_offsets.remove(record);
@@ -1268,6 +1374,7 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((output.to_string(), None))
})?;
s.doc_mut().prune_model_params();
let mut response = json!({"curve":out.0,"sketch":info});
if let Some(curves) = out.1 {
response["curves"] = json!(curves);
+228 -5
View File
@@ -1,6 +1,6 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::CurveKind;
use solidcraft_sketch::{ConstraintKind, CurveKind};
use crate::Session;
@@ -1062,8 +1062,10 @@ fn offset_symmetric_puts_the_same_distance_on_both_sides_of_a_chain() {
// The two distances are equal and opposite.
let d: Vec<f64> = o["sides"].as_array().unwrap().iter().map(|x| x["distance"].as_f64().unwrap()).collect();
assert!(close(d[0], -d[1]) && close(d[0].abs(), 5.0), "{d:?}");
// Both sides are parallel to their sources, so each one added four constraints.
assert_eq!(ids(&o["constraints"]).len(), 8, "{o}");
// Each side has four parallels and a persistent offset-distance driver.
assert_eq!(ids(&o["constraints"]).len(), 10, "{o}");
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).count(), 8);
assert_eq!(sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })).count(), 2);
}
#[test]
@@ -1171,9 +1173,9 @@ fn offset_refuses_the_combinations_that_would_be_guesswork() {
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "symmetric": true, "distance2": 3}));
assert!(e.contains("`distance2` or `symmetric`"), "{e}");
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": -3}));
assert!(e.contains("`distance2` must be a positive number"), "{e}");
assert!(e.contains("`distance2` must be a positive length"), "{e}");
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": 0}));
assert!(e.contains("`distance2` must be a positive number"), "{e}");
assert!(e.contains("`distance2` must be a positive length"), "{e}");
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "negative"}));
assert!(e.contains("no negative direction"), "{e}");
let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "sideways"}));
@@ -1199,3 +1201,224 @@ fn offset_one_side_still_reports_one_set_of_curves_and_its_association() {
assert_eq!(ids(&o["curves"]), ids(&o["sides"][0]["curves"]), "{o}");
assert!(close(o["sides"][0]["distance"].as_f64().unwrap().abs(), 0.2), "{o}");
}
#[test]
fn offset_driver_edits_reopens_and_undoes_without_moving_sources() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
let original = sketch(&s).points.clone();
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"2 mm","side":[20,-10]}));
let param = result["param"].as_str().unwrap().to_string();
let outputs = ids(&result["curves"]);
let check = |s: &Session, distance: f64| {
let state = s.world_state();
let sk = &state.sketch(1).unwrap().sketch;
assert!((sk.offset_distance(&outputs[0]).unwrap().abs() - distance).abs() < 1e-8);
let constraint = sk.constraints.iter().find(|c| c.param.as_deref() == Some(&param)).unwrap();
assert!(solidcraft_sketch::dim_frame(sk, &constraint.kind).is_some());
for (a, b) in sk.points.iter().zip(&original) {
assert!(a.pos.dist(b.pos) < 1e-7);
}
let boundary =
outputs.iter().flat_map(|id| sk.segs(sk.curve_index(id).unwrap())).flat_map(|s| [s.point_at(0.0), s.point_at(1.0)]).collect::<Vec<_>>();
let min_x = boundary.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let max_x = boundary.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max);
let min_y = boundary.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
let max_y = boundary.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max);
assert!((min_x + distance).abs() < 1e-7 && (max_x - 40.0 - distance).abs() < 1e-7);
assert!((min_y + distance).abs() < 1e-7 && (max_y - 30.0 - distance).abs() < 1e-7);
};
check(&s, 2.0);
run(&mut s, "parameters.change", json!({"name":param,"expression":"4 mm"}));
check(&s, 4.0);
run(&mut s, "edit.undo", json!({}));
check(&s, 2.0);
run(&mut s, "edit.redo", json!({}));
check(&s, 4.0);
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
check(&reopened, 4.0);
run(&mut reopened, "parameters.change", json!({"name":param,"expression":"3 mm"}));
check(&reopened, 3.0);
}
#[test]
fn offset_driver_covers_disconnected_selections_and_refuses_bad_values_atomically() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
run(&mut s, "sketch.circle.center", json!({"center":[30,0],"radius":10}));
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","c1"],"distance":"2 mm","chain":false}));
assert_eq!(result["curves"].as_array().unwrap().len(), 2);
let param = result["param"].as_str().unwrap();
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
assert!(sketch(&s).offsets.iter().all(|offset| (offset.distance.abs() - 3.0).abs() < 1e-8));
let before = s.doc.to_json();
let undo = s.undo.len();
for value in [json!(0), json!("0 mm"), json!("1 deg"), json!("1e20 mm")] {
assert!(s.execute("sketch.offset", &json!({"curves":["l1"],"distance":value})).is_err());
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
assert!(s.execute("sketch.offset", &json!({"curves":["l1","missing"],"distance":1})).is_err());
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn offset_driver_delete_and_break_remove_annotation_and_preserve_geometry() {
for source in ["rectangle", "ellipse"] {
let mut s = new_sketch();
let id = if source == "rectangle" {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
"l1"
} else {
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[20,0],"minor_radius":10}));
"e1"
};
let result = run(&mut s, "sketch.offset", json!({"curves":[id],"distance":1}));
let outputs = ids(&result["curves"]);
let param = result["param"].as_str().unwrap();
run(&mut s, "sketch.offset.break", json!({"curve":outputs[0]}));
assert!(!sketch(&s).constraints.iter().any(|c| c.param.as_deref() == Some(param)));
assert!(s.doc.param(param).is_none());
assert!(outputs.iter().all(|id| sketch(&s).curve_index(id).is_some()));
run(&mut s, "edit.undo", json!({}));
assert!(sketch(&s).constraints.iter().any(|c| c.param.as_deref() == Some(param)));
run(&mut s, "sketch.delete", json!({"entities":outputs}));
assert!(s.doc.param(param).is_none());
}
}
#[test]
fn offset_driver_uses_design_units_and_dimension_delete_freezes_current_distance() {
let mut s = new_sketch();
run(&mut s, "document.units", json!({"units":"in"}));
run(&mut s, "sketch.line", json!({"points":[[0,0],[100,0]]}));
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"0.1","chain":false}));
let param = result["param"].as_str().unwrap();
assert!((sketch(&s).offsets[0].distance - 2.54).abs() < 1e-8);
run(&mut s, "parameters.change", json!({"name":param,"expression":"0.2 in"}));
let sk = sketch(&s);
let annotation = sk.constraints.iter().find(|c| c.param.as_deref() == Some(param)).unwrap().id.clone();
run(&mut s, "sketch.delete", json!({"entities":[annotation]}));
assert!((sketch(&s).offsets[0].distance - 5.08).abs() < 1e-8);
assert!(s.doc.param(param).is_none());
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((reopened.world_state().sketch(1).unwrap().sketch.offsets[0].distance - 5.08).abs() < 1e-8);
}
#[test]
fn offset_driver_arc_first_retires_breaks_undoes_and_reopens() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,-15],"p1":[20,15]}));
let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"2 mm"}));
let arc = fillet["curves"][0].as_str().unwrap();
let result = run(&mut s, "sketch.offset", json!({"curves":[arc],"distance":"1 mm"}));
let param = result["param"].as_str().unwrap();
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
let sk = sketch(&s);
let driver = sk.constraints.iter().find(|c| c.param.as_deref() == Some(param)).unwrap();
let solidcraft_sketch::ConstraintKind::OffsetDistance { output, .. } = &driver.kind else {
panic!("missing offset driver");
};
let record = sk.offsets.iter().find(|o| o.owns_output(output)).unwrap();
assert!(!record.outputs.contains(output), "the dimension's first output arc must be absent from the live boundary");
assert!(record.topology.as_ref().unwrap().roles.iter().any(|r| r.output == *output && r.retired));
let survivor = sk.offsets[0].outputs.first().unwrap().clone();
let before = sk.segs(sk.curve_index(&survivor).unwrap());
run(&mut s, "sketch.offset.break", json!({"curve":survivor}));
assert!(s.doc.param(param).is_none());
assert!(sketch(&s).offsets.is_empty());
assert_eq!(sketch(&s).segs(sketch(&s).curve_index(&survivor).unwrap()), before);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!(reopened.world_state().sketch(1).unwrap().sketch.offsets.is_empty());
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.param(param).is_some());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.param(param).is_none());
}
#[test]
fn offset_driver_symmetric_disconnected_sources_share_one_distance() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
run(&mut s, "sketch.line", json!({"points":[[20,20],[30,20]]}));
let source = sketch(&s).points.clone();
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","l2"],"distance":"2 mm","symmetric":true,"chain":false}));
assert_eq!(ids(&result["curves"]).len(), 4);
assert_eq!(result["sides"].as_array().unwrap().len(), 4);
let param = result["param"].as_str().unwrap();
assert!(result["sides"].as_array().unwrap().iter().all(|side| side["param"] == param));
run(&mut s, "parameters.change", json!({"name":param,"expression":"3 mm"}));
let sk = sketch(&s);
assert_eq!(sk.offsets.len(), 4);
assert!(sk.offsets.iter().all(|offset| (offset.distance.abs() - 3.0).abs() < 1e-8));
assert!(sk.points.iter().zip(&source).all(|(a, b)| a.pos.dist(b.pos) < 1e-8));
// Breaking one side keeps the shared parameter while another association uses it.
let first = result["curves"][0].as_str().unwrap();
run(&mut s, "sketch.offset.break", json!({"curve":first}));
assert!(s.doc.param(param).is_some());
run(&mut s, "parameters.change", json!({"name":param,"expression":"4 mm"}));
assert!(sketch(&s).offsets.iter().all(|offset| (offset.distance.abs() - 4.0).abs() < 1e-8));
}
#[test]
fn offset_driver_two_sides_edit_independently_and_reopen() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":"2 mm","distance2":"1 mm","side":[20,15]}));
let primary = result["param"].as_str().unwrap();
let secondary = result["param2"].as_str().unwrap();
assert_ne!(primary, secondary);
let inside = side_curves(&result, 0);
let outside = side_curves(&result, 1);
run(&mut s, "parameters.change", json!({"name":primary,"expression":"4 mm"}));
assert!(box_is(&sketch(&s), &inside, [4.0, 4.0], [36.0, 26.0]));
assert!(box_is(&sketch(&s), &outside, [-1.0, -1.0], [41.0, 31.0]));
run(&mut s, "parameters.change", json!({"name":secondary,"expression":"3 mm"}));
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
assert!(box_is(&sketch(&reopened), &inside, [4.0, 4.0], [36.0, 26.0]));
assert!(box_is(&sketch(&reopened), &outside, [-3.0, -3.0], [43.0, 33.0]));
run(&mut s, "edit.undo", json!({}));
assert!(box_is(&sketch(&s), &outside, [-1.0, -1.0], [41.0, 31.0]));
run(&mut s, "edit.redo", json!({}));
assert!(box_is(&sketch(&s), &outside, [-3.0, -3.0], [43.0, 33.0]));
let before = s.doc.to_json();
for second in [json!(0), json!(-1), json!("1 deg"), json!("1e20 mm")] {
assert!(s.execute("sketch.offset", &json!({"curves":["l1"],"distance":1,"distance2":second})).is_err());
assert_eq!(s.doc.to_json(), before);
}
}
#[test]
fn offset_driver_later_groups_never_walk_outputs_created_by_earlier_groups() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
run(&mut s, "sketch.line", json!({"points":[[10,2],[20,2]]}));
// The first offset ends exactly on the second source; it must not join its chain.
let result = run(&mut s, "sketch.offset", json!({"curves":["l1","l2"],"distance":2}));
assert_eq!(ids(&result["curves"]).len(), 2);
let sk = sketch(&s);
assert_eq!(sk.offsets.len(), 2);
assert_eq!(sk.offsets[0].sources, vec![("l1".into(), true)]);
assert_eq!(sk.offsets[1].sources, vec![("l2".into(), true)]);
}
#[test]
fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]]}));
let result = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":2}));
let param = result["param"].as_str().unwrap();
let mut doc = (*s.doc).clone();
doc.sketch_mut(1).unwrap().constraints.retain(|c| !matches!(c.kind, ConstraintKind::OffsetDistance { .. }));
doc.params.retain(|p| p.name != param);
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = json!("solidcraft/18");
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
assert_eq!(reopened.doc.format, "solidcraft/19");
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
let sk = sketch(&reopened);
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);
assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
assert_eq!(ids(&result["curves"]).len(), sk.offsets[0].outputs.len());
}
+4
View File
@@ -50,6 +50,10 @@ pub fn dim_frame(sk: &Sketch, k: &ConstraintKind) -> Option<DimFrame> {
use ConstraintKind::*;
let unit = |v: Vec2, or: Vec2| v.normalized().unwrap_or(or);
Some(match *k {
OffsetDistance { ref output, .. } => {
let (p0, p1) = sk.offset_dimension_points(output)?;
DimFrame::Linear { p0, p1, dir: unit(p1 - p0, Vec2::X) }
}
Distance { p, q, .. } => {
let (a, b) = (sk.point(p)?, sk.point(q)?);
DimFrame::Linear { p0: a, p1: b, dir: unit(b - a, Vec2::X) }
+3 -1
View File
@@ -45,7 +45,9 @@ pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, font_file_path, installed_families};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
pub use model::{
Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_COORD, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView,
};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use periodic::periodic_beziers;
+20 -1
View File
@@ -10,7 +10,7 @@ pub const MAX_CONSTRAINTS: usize = 40_000;
pub const MAX_CURVES: usize = MAX_POINTS;
/// Largest coordinate or dimension value (mm) a sketch accepts.
pub(crate) const MAX_COORD: f64 = 1e9;
pub const MAX_COORD: f64 = 1e9;
/// A position a sketch accepts: finite and within [`MAX_COORD`].
pub(crate) fn in_range(p: Vec2) -> bool {
@@ -310,6 +310,15 @@ pub enum ConstraintKind {
l: usize,
value: f64,
},
/// Normal distance of an associative offset. Its output id remains stable
/// when a curved boundary retires a corner; refresh_offsets drives the geometry.
OffsetDistance {
output: String,
value: f64,
/// Keep the chosen side when the positive driving length is edited.
#[serde(default)]
reverse: bool,
},
}
impl ConstraintKind {
@@ -331,6 +340,7 @@ impl ConstraintKind {
| Diameter { value, .. }
| ArcLength { value, .. }
| LinearDiameter { value, .. }
| OffsetDistance { value, .. }
| Angle { value, .. } => Some(value),
_ => None,
}
@@ -350,6 +360,7 @@ impl ConstraintKind {
| Diameter { value, .. }
| ArcLength { value, .. }
| LinearDiameter { value, .. }
| OffsetDistance { value, .. }
| Angle { value, .. } => *value = v,
_ => {}
}
@@ -381,6 +392,7 @@ impl ConstraintKind {
Smooth { .. } => "Smooth",
ArcLength { .. } => "ArcLengthDimension",
LinearDiameter { .. } => "LinearDiameterDimension",
OffsetDistance { .. } => "OffsetDimension",
}
}
/// Does the constraint refer to point `p` (by index)?
@@ -406,6 +418,7 @@ impl ConstraintKind {
Smooth { a, b } => (vec![], vec![a, b]),
ArcLength { c, .. } => (vec![], vec![c]),
LinearDiameter { p, l, .. } => (vec![p], vec![l]),
OffsetDistance { .. } => (vec![], vec![]),
}
}
pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
@@ -462,6 +475,7 @@ impl ConstraintKind {
fp(p);
fc(l);
}
OffsetDistance { .. } => {}
}
ok && ok2
}
@@ -868,6 +882,11 @@ impl Sketch {
{
return Err(SketchError::Invalid(format!("dimension value {v}")));
}
if let OffsetDistance { output, value, .. } = k
&& (*value <= 1e-9 || output.len() > 512 || self.offset_distance(output).is_none())
{
return Err(SketchError::Invalid("offset dimension needs a nonzero distance and an existing association".into()));
}
if let ArcSweep { value, .. } = *k
&& !(value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9)
{
+54 -2
View File
@@ -1,5 +1,5 @@
//! Associative offsets keep their output entity ids while their source curves move.
use crate::{CurveKind, Shape, Sketch, SketchError, intersections};
use crate::{ConstraintKind, CurveKind, Shape, Sketch, SketchError, intersections};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
@@ -13,7 +13,37 @@ pub struct OffsetChain {
pub topology: Option<crate::offset_topology::OffsetTopology>,
}
impl OffsetChain {
/// Includes reserved corner roles that may temporarily disappear from the boundary.
pub fn owns_output(&self, output: &str) -> bool {
self.outputs.iter().any(|id| id == output) || self.topology.as_ref().is_some_and(|t| t.roles.iter().any(|r| r.output == output))
}
}
impl Sketch {
/// The signed distance of the association that owns an output (including retired roles).
pub fn offset_distance(&self, output: &str) -> Option<f64> {
self.offsets
.iter()
.find(|o| o.owns_output(output))
.map(|o| o.distance)
.or_else(|| self.freeform_offsets.iter().find(|o| o.output == output).map(|o| o.distance))
}
/// Endpoints of a normal-distance annotation, evaluated from its source curve.
pub fn offset_dimension_points(&self, output: &str) -> Option<(Vec2, Vec2)> {
let (source, forward, distance) = self
.offsets
.iter()
.find(|o| o.owns_output(output))
.and_then(|o| o.sources.first().map(|(id, forward)| (id.as_str(), *forward, o.distance)))
.or_else(|| self.freeform_offsets.iter().find(|o| o.output == output).map(|o| (o.source.as_str(), o.forward, o.distance)))?;
let index = self.curve_index(source)?;
let segment = self.exact_segs(index).unwrap_or_else(|| self.segs(index)).into_iter().next()?;
let p = segment.point_at(0.5);
let tangent = (segment.point_at(0.5001) - segment.point_at(0.4999)).normalized()?;
Some((p, p + tangent.perp() * if forward { distance } else { -distance }))
}
/// Removing an output removes its relationship. A partial analytic chain
/// deletion releases the surviving outputs, while retained dependents keep
/// their source references so the command's refresh refuses broken sources.
@@ -26,11 +56,12 @@ impl Sketch {
.offsets
.iter()
.filter(|offset| offset.outputs.iter().any(|id| removed.contains(id)))
.flat_map(|offset| offset.outputs.iter().cloned())
.flat_map(|offset| offset.outputs.iter().cloned().chain(offset.topology.iter().flat_map(|t| t.roles.iter().map(|r| r.output.clone()))))
.chain(self.freeform_offsets.iter().filter(|offset| removed.contains(&offset.output)).map(|offset| offset.output.clone()))
.collect::<std::collections::BTreeSet<_>>();
self.offsets.retain(|offset| !offset.outputs.iter().any(|id| removed.contains(id)));
self.freeform_offsets.retain(|offset| !removed.contains(&offset.output));
self.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output, .. } if released.contains(output)));
let points = self
.curves
.iter()
@@ -52,6 +83,27 @@ impl Sketch {
return Ok(());
}
let mut candidate = self.clone();
let mut driven = std::collections::BTreeSet::new();
for constraint in &self.constraints {
if let ConstraintKind::OffsetDistance { output, value, reverse } = &constraint.kind {
self.validate(&constraint.kind)?;
let analytic = candidate.offsets.iter().position(|o| o.owns_output(output));
let freeform = candidate.freeform_offsets.iter().position(|o| o.output == *output);
let identity = analytic
.or_else(|| freeform.map(|i| candidate.offsets.len() + i))
.ok_or_else(|| SketchError::Invalid("missing offset dimension association".into()))?;
if constraint.driven || !driven.insert(identity) {
return Err(SketchError::Invalid("an offset must have exactly one driving distance".into()));
}
let distance = if *reverse { -*value } else { *value };
if let Some(offset) = analytic.and_then(|i| candidate.offsets.get_mut(i)) {
offset.distance = distance;
}
if let Some(offset) = freeform.and_then(|i| candidate.freeform_offsets.get_mut(i)) {
offset.distance = distance;
}
}
}
candidate.refresh_analytic_offsets()?;
candidate.refresh_freeform_offsets()?;
*self = candidate;
+1
View File
@@ -829,6 +829,7 @@ impl Sketch {
}
}
candidate.offsets.remove(index);
candidate.constraints.retain(|c| !matches!(&c.kind, crate::ConstraintKind::OffsetDistance { output, .. } if record.owns_output(output)));
for point in owned {
if point != 0
&& candidate.curves.iter().all(|c| !c.kind.uses(point) || outputs.contains(c.id.as_str()))
+4 -2
View File
@@ -400,6 +400,8 @@ fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
use ConstraintKind::*;
match *k {
// Offset distances drive derived geometry in refresh_offsets, never source DOFs.
OffsetDistance { .. } => {}
Coincident { p, q } => {
let d = s.p(p) - s.p(q);
out.extend([d.x, d.y]);
@@ -782,7 +784,7 @@ fn block_refs(sk: &Sketch, b: &Block) -> Vec<Ref> {
Tangent { a, b } => tangent(a, b).to_vec(),
Smooth { a, b } => vec![C(a), C(b)],
Symmetric { p, q, l } => vec![P(p), P(q), L(l)],
Fix { .. } => vec![],
Fix { .. } | OffsetDistance { .. } => vec![],
},
Block::TangentAt(_, k, p) => match **k {
ConstraintKind::Tangent { a, b } if freeform(a) || freeform(b) => vec![P(*p), C(a), C(b)],
@@ -1343,7 +1345,7 @@ fn build_blocks<'a>(sk: &'a Sketch, lay: &Layout, on_curve: &[(usize, usize)]) -
}
}
for (i, c) in sk.constraints.iter().enumerate() {
if c.driven {
if c.driven || matches!(c.kind, ConstraintKind::OffsetDistance { .. }) {
continue;
}
raw.push(match touch_point(sk, &c.kind, on_curve) {
+63 -7
View File
@@ -330,7 +330,7 @@ fn fits(a: Accept, s: &Sel) -> bool {
Sel::Feature { .. } => a & selection::FEATURES != 0,
// Sketch points go into a sketch command's own inputs (Entities, a base point), so a
// box selection and a pre-selection reach them the way a click already does.
Sel::SketchPoint { .. } => a & selection::POINTS != 0,
Sel::SketchPoint { .. } => a & (selection::POINTS | selection::SKETCH_POINTS) != 0,
Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => false,
}
}
@@ -645,6 +645,7 @@ impl Dialog {
Kind::Assembly(k) => return k.previews(),
Kind::Sheet(k) => return k.previews(),
Kind::Surface(_) => return true,
Kind::SketchModify(k) => return k.previews(),
Kind::Plastic(k) => return k.previews(),
Kind::Part(_) => return true,
_ => {}
@@ -775,6 +776,9 @@ impl Dialog {
/// ([`Dialog::face_snap`] of the same hits). A face place picked on or near a sketch point that
/// lies on that face (a circle's centre) is taken at the point, as a points input picks it.
pub(crate) fn candidate_on(&self, s: &Session, h: &Hit, snap: Option<&FaceSnap>) -> Option<Sel> {
if matches!(self.kind, Kind::SketchModify(_)) {
return crate::sketch_modify_ui::candidate(self, s, h);
}
let inp = self.active_input()?;
// Features are also picked on the canvas: a face stands for the feature that made it.
if inp.accept == selection::FEATURES {
@@ -856,7 +860,15 @@ impl Dialog {
}
for x in sels {
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
if !fits(inp.accept, &x) {
continue;
}
if !inp.multi {
inp.items.clear();
inp.items.push(x);
break;
}
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -1313,7 +1325,7 @@ fn hint(inp: &SelInput) -> &'static str {
"click an axis or sketch line"
} else if a & CURVES != 0 {
"click sketch curves"
} else if a & selection::POINTS != 0 {
} else if a & (selection::POINTS | selection::SKETCH_POINTS) != 0 {
"click sketch points"
} else if a & BODIES != 0 {
"click bodies"
@@ -1496,9 +1508,26 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
}
}
fn selection_label(s: &Session, item: &Sel) -> String {
match item {
Sel::SketchCurve { id } => id.clone(),
Sel::SketchPoint { id } => crate::sketch_modify_ui::point_id(s, item).unwrap_or(id).to_string(),
Sel::Plane { name } | Sel::Axis { name } | Sel::Body { name } => name.clone(),
Sel::Face { body, index, .. } => format!("{body} · face {index}"),
Sel::Edge { body, index, .. } => format!("{body} · edge {index}"),
Sel::Feature { id } => s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
Sel::Vertex { body, point } if body.is_empty() => {
let state = s.world_state();
let p = s.active_sketch.and_then(|id| state.sketch(id)).map(|sk| sk.plane.to_local(*point)).unwrap_or_default();
format!("({:.2}, {:.2}) mm", p.x, p.y)
}
_ => format!("{item:?}"),
}
}
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
/// makes it the active input.
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
return;
@@ -1516,6 +1545,8 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let text = if filled {
RichText::new(if custom_axis {
crate::i18n::tr("dialog.custom_axis").into()
} else if !inp.multi {
inp.items.first().map(|item| selection_label(s, item)).unwrap_or_default()
} else {
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
})
@@ -1526,6 +1557,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
// Not in the Tab order: Tab moves between values.
let b = egui::Button::new(text)
.wrap_mode(egui::TextWrapMode::Wrap)
.fill(if filled { t.chip } else { Color32::TRANSPARENT })
.stroke(stroke)
.min_size(vec2(0.0, 22.0))
@@ -1548,8 +1580,12 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
if chip.on_hover_text(tooltip).clicked() {
activate = Some(i);
}
if !inp.items.is_empty() && ui.small_button("×").on_hover_text(tr("Clear the selection")).clicked() {
clear = Some(i);
if !inp.items.is_empty() {
let button = ui.small_button("×").on_hover_text(tr("Clear the selection"));
crate::scenario::publish_handle(&format!("dialog:clear:{}", inp.label), button.rect.center());
if button.clicked() {
clear = Some(i);
}
}
});
ui.end_row();
@@ -1845,7 +1881,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
placement_row(&mut d, ui);
input_rows(&mut d, ui);
if matches!(&d.kind, Kind::SketchModify(k) if k.previews())
|| matches!(d.kind, Kind::Mirror | Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. })
{
row_label(ui, "dialog.step");
let label = d.active_input().map(|input| tr(input.label)).unwrap_or_else(|| tr("Objects"));
ui.label(crate::i18n::format(
"dialog.selection_step",
&[("step", &(d.active + 1).to_string()), ("total", &d.inputs.len().to_string()), ("input", label)],
));
ui.end_row();
row_label(ui, "");
ui.label(tr("dialog.selection_guidance"));
ui.end_row();
}
input_rows(&mut d, ui, &app.session);
match &mut d.kind {
Kind::Sketch => {
if d.error.is_some()
@@ -2480,6 +2530,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
}
if matches!(d.kind, Kind::SketchModify(_))
&& d.previews()
&& let Err(error) = apply_commands(app, &d)
{
d.refuse(app, error);
}
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
row_label(ui, "");
ui.vertical(|ui| {
+2
View File
@@ -111,6 +111,8 @@ mod sketch_drag_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
mod sketch_families;
#[cfg(test)]
mod sketch_guidance_ui_tests;
pub mod sketch_modify_ui;
#[cfg(test)]
mod sketch_modify_ui_tests;
+74 -4
View File
@@ -33,9 +33,16 @@ impl Colors {
}
}
pub struct SketchPreview {
pub sketch: std::sync::Arc<solidcraft_engine::doc::SolvedSketch>,
pub curves: Vec<usize>,
pub points: Vec<usize>,
}
/// A computed preview: the scene to draw and the bodies it stands in for.
pub struct Built {
pub scene: GpuScene,
pub sketches: Vec<SketchPreview>,
pub replaced: Vec<String>,
/// Why the feature failed, when the scene only shows its tool body.
pub error: Option<String>,
@@ -64,6 +71,7 @@ pub struct PreviewState {
pub key: u64,
/// Is a preview scene up?
pub active: bool,
pub sketches: Vec<SketchPreview>,
/// Why the current inputs don't make a feature (the last good preview stays up).
pub error: Option<String>,
/// A preview is being computed.
@@ -87,6 +95,7 @@ impl PreviewState {
self.error = None;
self.applied = None;
self.tapered.clear();
self.sketches.clear();
self.busy = false;
if self.active || !self.replaced.is_empty() {
self.active = false;
@@ -108,6 +117,7 @@ impl PreviewState {
self.ms = crate::now_ms() - self.started;
match job {
Ok(b) => {
self.sketches = b.sketches;
self.replaced = b.replaced;
self.bounds = b.bounds;
self.active = true;
@@ -117,6 +127,7 @@ impl PreviewState {
self.set_scene(b.scene);
}
Err(e) => {
self.sketches.clear();
self.error = Some(e);
self.applied = None;
self.tapered.clear();
@@ -128,6 +139,7 @@ impl PreviewState {
/// computing new inputs, but never let an obsolete result block their Apply.
fn poll(&mut self, key: u64, motion_key: Option<u64>) {
if self.shown_key != Some(key) {
self.sketches.clear();
self.error = None;
}
if let Some((k, rx)) = &self.running {
@@ -241,7 +253,7 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
/// kernel can't do yet), the tool body is still shown, with the error.
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
if cmds.iter().any(|(command, params)| {
command == "sketch.redefine"
matches!(command.as_str(), "sketch.redefine" | "sketch.mirror" | "sketch.pattern.rectangular" | "sketch.pattern.circular" | "sketch.offset")
|| command == "construct.plane.along_path"
|| params.get("command").and_then(Value::as_str) == Some("construct.plane.along_path")
}) {
@@ -315,7 +327,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
}
}
}
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
Built { scene: sc, sketches: Vec::new(), replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
}
/// The same commands making a new body instead of joining, cutting or intersecting, and the
@@ -425,7 +437,34 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
replaced.push(b.name.clone());
}
}
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
let sketches = after
.sketches
.iter()
.filter_map(|sketch| {
let old = before.sketch(sketch.feature);
let old_curves = old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| c.id.as_str())).collect::<std::collections::HashSet<_>>();
let old_points = old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| p.id.as_str())).collect::<std::collections::HashSet<_>>();
let indices = sketch
.sketch
.curves
.iter()
.enumerate()
.filter_map(|(index, curve)| {
let unchanged = old_curves.contains(curve.id.as_str());
if unchanged { None } else { Some(index) }
})
.collect::<Vec<_>>();
let points = sketch
.sketch
.points
.iter()
.enumerate()
.filter_map(|(i, p)| if old_points.contains(p.id.as_str()) { None } else { Some(i) })
.collect::<Vec<_>>();
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
})
.collect();
Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
}
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
@@ -544,6 +583,29 @@ pub fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
}
/// The same computed sketch preview is drawn over both native GPU and CPU viewports.
pub fn paint_sketches(app: &SolveApp, painter: &egui::Painter, proj: &crate::viewport::Proj) {
if !app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)) && d.previews()) {
return;
}
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
let mut curves = 0;
for preview in &app.preview.sketches {
let sketch = &preview.sketch;
for index in &preview.curves {
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, &mut budget);
curves += 1;
}
for index in &preview.points {
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
painter.circle_filled(point, 3.0, stroke.color);
}
}
}
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -589,7 +651,15 @@ mod tests {
}
fn pose() -> Built {
Built { scene: GpuScene::default(), replaced: vec!["Lid".into()], error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
Built {
scene: GpuScene::default(),
sketches: Vec::new(),
replaced: vec!["Lid".into()],
error: None,
bounds: Aabb3::EMPTY,
applied: None,
tapered: Vec::new(),
}
}
/// The worker completes while the slider is still held or Play keeps advancing.
+3
View File
@@ -36,6 +36,8 @@ pub const CURVES: Accept = 256;
pub const POINTS: Accept = 512;
/// Timeline features (pattern objects), picked in the timeline.
pub const FEATURES: Accept = 1024;
/// Points of the sketch being modified; unlike POINTS these never enable cross-sketch picking.
pub const SKETCH_POINTS: Accept = 2048;
/// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...).
#[derive(Clone, Debug, PartialEq)]
@@ -66,6 +68,7 @@ impl SelInput {
Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }),
Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
Hit::SketchCurve { id, .. } if a & CURVES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
Hit::SketchPoint { id, .. } if a & SKETCH_POINTS != 0 => Some(Sel::SketchPoint { id: id.clone() }),
Hit::SketchPoint { sketch, id, .. } if a & POINTS != 0 => Some(Sel::SketchPoint { id: format!("{sketch}:{id}") }),
Hit::Vertex { body, point } if a & VERTICES != 0 => Some(Sel::Vertex { body: body.clone(), point: *point }),
_ => None,
@@ -0,0 +1,257 @@
#![allow(clippy::unwrap_used)]
use crate::scenario::Harness;
use crate::sketch_modify_ui::{Mod, candidate};
use crate::viewport::Hit;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
fn step(h: &mut Harness, value: Value) {
h.step(&value).unwrap();
}
fn fixture() -> Harness {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.line", json!({"points":[[-30,10],[-15,10]]})),
("sketch.line", json!({"points":[[-15,10],[-15,25]]})),
("sketch.line", json!({"points":[[0,-10],[0,40]],"construction":true})),
] {
step(&mut h, json!({"call":"engine.execute","params":{"command":command,"params":params}}));
}
step(&mut h, json!({"call":"ui.view","params":{"view":"top"}}));
h
}
fn pick(h: &mut Harness, xy: [f64; 2], ctrl: bool) {
step(h, json!({"click":{"sketch":xy},"ctrl":ctrl}));
}
fn preview(h: &mut Harness, curves: usize) {
step(h, json!({"until":{"count":{"name":"sketch_preview_curves","value":curves}}}));
}
#[test]
fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
let mut h = fixture();
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let revision = h.app.session.revision;
step(&mut h, json!({"start":"sketch.mirror"}));
assert!(h.app.tool.is_none());
pick(&mut h, [-22.5, 10.0], false);
pick(&mut h, [-15.0, 17.5], false);
pick(&mut h, [-22.5, 10.0], true);
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
pick(&mut h, [-22.5, 10.0], true);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [0.0, 20.0], false);
preview(&mut h, 2);
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.revision, revision);
step(&mut h, json!({"click":{"handle":"dialog:clear:Mirror line"}}));
assert!(h.app.dialog.as_ref().unwrap().inputs[1].items.is_empty());
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2);
pick(&mut h, [0.0, 20.0], false);
preview(&mut h, 2);
step(&mut h, json!({"key":"Escape"}));
h.frames(3);
assert!(h.app.dialog.is_none());
assert!(!h.app.preview.active);
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
let mut h = fixture();
step(&mut h, json!({"start":"sketch.pattern.rectangular"}));
pick(&mut h, [-22.5, 10.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::PatternRect { count, count2, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
*count = "4".into();
*count2 = "2".into();
}
h.frames(3);
preview(&mut h, 7);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 10);
step(&mut h, json!({"start":"sketch.pattern.circular"}));
pick(&mut h, [-22.5, 10.0], false);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [-40.0, -20.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle }) = &mut h.app.dialog.as_mut().unwrap().kind {
*count = "4".into();
*angle = "180 deg".into();
}
h.frames(3);
preview(&mut h, 3);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 13);
}
#[test]
fn sketch_dialog_offset_precise_distance_flip_and_editable_dimension() {
let mut h = fixture();
step(&mut h, json!({"start":"sketch.offset"}));
pick(&mut h, [-22.5, 10.0], false);
if let crate::dialogs::Kind::SketchModify(Mod::Offset { distance, flip, chain, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
*distance = "2 mm".into();
*flip = true;
*chain = false;
}
h.frames(3);
preview(&mut h, 1);
step(&mut h, json!({"click":{"handle":"dialog:OK"}}));
let param = h.app.session.doc.params.iter().find(|p| p.name.starts_with('d')).unwrap().name.clone();
step(&mut h, json!({"call":"engine.execute","params":{"command":"parameters.change","params":{"name":param,"expression":"3 mm"}}}));
let state = h.app.session.world_state();
let sk = &state.sketch(1).unwrap().sketch;
assert_eq!(sk.offsets[0].distance, -3.0);
assert!(sk.constraints.iter().any(|c| matches!(c.kind, solidcraft_engine::sketch::ConstraintKind::OffsetDistance { .. })));
}
#[test]
fn sketch_dialog_mirror_refuses_external_and_curved_references() {
let mut h = fixture();
h.app.start("sketch.mirror");
let d = h.app.dialog.as_mut().unwrap();
d.active = 1;
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 2, id: "l1".into(), straight: true }).is_none());
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 1, id: "c1".into(), straight: false }).is_none());
}
#[test]
fn sketch_dialog_box_selection_adds_replaces_and_preserves_reference() {
let mut h = fixture();
step(&mut h, json!({"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-50,-20],[-40,-20]]}}}));
step(&mut h, json!({"start":"sketch.mirror"}));
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
pick(&mut h, [0.0, 20.0], false);
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":0}}));
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,27]}]}));
let sources = |h: &Harness| {
h.app.dialog.as_ref().unwrap().inputs[0]
.items
.iter()
.filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None })
.collect::<Vec<_>>()
};
assert_eq!(sources(&h), vec!["l1", "l2"]);
step(&mut h, json!({"drag":[{"sketch":[-52,-22]},{"sketch":[-38,-18]}],"shift":true}));
assert_eq!(sources(&h), vec!["l1", "l2", "l4"]);
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,12]}]}));
assert_eq!(sources(&h), vec!["l1"]);
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[1].items, vec![Sel::SketchCurve { id: "l3".into() }]);
step(&mut h, json!({"key":"Escape"}));
step(&mut h, json!({"start":"sketch.offset"}));
step(&mut h, json!({"drag":[{"sketch":[-32,8]},{"sketch":[-13,27]}]}));
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2, "Offset accepts curves, not their endpoints or dimensions");
}
#[test]
fn sketch_modify_inputs_refuse_other_sketches_without_changing_hole_points() {
let mut h = fixture();
for command in ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular", "sketch.offset", "sketch.move", "sketch.scale"] {
h.app.start(command);
let d = h.app.dialog.as_ref().unwrap();
assert!(candidate(d, &h.app.session, &Hit::SketchCurve { sketch: 2, id: "l1".into(), straight: true }).is_none());
assert!(candidate(d, &h.app.session, &Hit::SketchPoint { sketch: 2, id: "p1".into(), at: solidcraft_engine::geom::Vec2::ZERO }).is_none());
if command != "sketch.offset" {
assert!(d.inputs[0].accept & crate::selection::SKETCH_POINTS != 0);
assert_eq!(d.inputs[0].accept & crate::selection::POINTS, 0);
}
}
let input = crate::selection::SelInput::new("Hole points", crate::selection::POINTS, true);
assert_eq!(
input.accepts(&Hit::SketchPoint { sketch: 2, id: "p1".into(), at: solidcraft_engine::geom::Vec2::ZERO }, |_, _| false),
Some(Sel::SketchPoint { id: "2:p1".into() })
);
h.app.start("sketch.pattern.circular");
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id: "l1".into() }];
d.inputs[1].items = vec![Sel::SketchPoint { id: "2:p1".into() }];
assert!(crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err());
}
#[test]
fn sketch_preselected_points_toggle_once_and_empty_shift_box_keeps_reference() {
let mut h = fixture();
let state = h.app.session.world_state();
let point = state.sketch(1).unwrap().sketch.points[1].id.clone();
h.app.session.selection = vec![Sel::SketchPoint { id: point.clone() }];
h.app.start("sketch.mirror");
let d = h.app.dialog.as_mut().unwrap();
assert_eq!(d.inputs[0].items, vec![Sel::SketchPoint { id: point.clone() }]);
d.active = 0;
let selected = candidate(d, &h.app.session, &Hit::SketchPoint { sketch: 1, id: point, at: solidcraft_engine::geom::Vec2::ZERO }).unwrap();
d.pick(&h.app.session, selected);
assert!(d.inputs[0].items.is_empty(), "clicking a preselection removes it instead of duplicating it");
d.active = 1;
let reference = Sel::SketchCurve { id: "l3".into() };
d.pick(&h.app.session, reference.clone());
d.take_box(&h.app.session, Vec::new(), true);
assert_eq!(d.inputs[1].items, vec![reference]);
d.take_box(&h.app.session, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }], true);
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "l1".into() }], "a box replaces a single reference with one item");
}
#[test]
fn sketch_modify_previews_use_the_active_occurrences_world_placement() {
let mut h = Harness::new();
let part = h.app.run("component.create", json!({"name":"Part"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
h.app.run("sketch.line", json!({"points":[[-30,0],[-20,0]]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,-10],[0,10]],"construction":true})).unwrap();
h.app
.run("occurrence.move", json!({"occurrence":part["occurrence"],"translate":[100,30,20],"axis":[0,0,1],"angle":"35 deg","capture":false}))
.unwrap();
let original = h.app.session.doc.to_json();
let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get());
for (command, params) in [
("sketch.mirror", json!({"entities":["l1"],"line":"l2"})),
("sketch.pattern.rectangular", json!({"entities":["l1"],"count":3,"spacing":10})),
("sketch.pattern.circular", json!({"entities":["l1"],"center":[0,0],"count":4})),
("sketch.offset", json!({"curves":["l1"],"distance":2,"chain":false})),
] {
let built = crate::preview::compute(&h.app.session, &[(command.into(), params.clone())], colors).unwrap();
let mut scratch = h.app.session.scratch();
scratch.execute(command, &params).unwrap();
let state = scratch.world_state();
let expected = state.sketch(sid).unwrap().plane;
assert!(expected.origin.len() > 50.0, "the fixture must exercise a moved occurrence");
assert_eq!(built.sketches.len(), 1);
assert!(!built.sketches[0].curves.is_empty());
assert_eq!(built.sketches[0].sketch.plane, expected, "{command}");
assert_eq!(h.app.session.doc.to_json(), original);
}
}
#[test]
fn sketch_corner_preselected_point_uses_the_same_identity_as_corner_picking() {
let mut h = fixture();
let state = h.app.session.world_state();
let sk = &state.sketch(1).unwrap().sketch;
let index = sk.resolve_point("l1.end").unwrap();
let point = sk.points[index].id.clone();
h.app.session.selection = vec![Sel::SketchPoint { id: point }];
h.app.start("sketch.fillet");
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
pick(&mut h, [-15.0, 10.0], true);
assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty());
}
#[test]
fn sketch_modify_keeps_custom_point_ids_that_contain_colons() {
let mut h = fixture();
h.app.run("sketch.point", json!({"point":[-40,0],"id":"2:centre"})).unwrap();
h.app.start("sketch.pattern.circular");
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id: "l1".into() }];
d.inputs[1].items = vec![Sel::SketchPoint { id: "2:centre".into() }];
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands[0].1["center"], "2:centre");
for (command, params) in commands {
h.app.run(&command, params).unwrap();
}
assert_eq!(h.app.session.world_state().sketch(1).unwrap().sketch.curves.len(), 8);
}
+75 -47
View File
@@ -31,7 +31,7 @@ use solidcraft_engine::geom::{Vec2, Vec3};
use crate::SolveApp;
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
use crate::selection::{AXES, CURVES, POINTS, SelInput};
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
use crate::viewport::{Hit, Proj};
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
@@ -91,6 +91,10 @@ pub enum Mod {
}
impl Mod {
pub fn previews(&self) -> bool {
matches!(self, Self::Mirror | Self::PatternRect { .. } | Self::PatternCirc { .. } | Self::Offset { .. })
}
pub fn title(&self) -> &'static str {
match self {
Mod::Scale { .. } => "SKETCH SCALE",
@@ -121,20 +125,20 @@ 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::Scale { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Base point", SKETCH_POINTS, false)],
Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)],
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)],
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Mirror line", AXES, false)],
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
// 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)],
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_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)],
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_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)],
Mod::Corner { .. } => vec![SelInput::new("Corner", POINTS, false)],
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS, true)],
Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS, false)],
}
}
@@ -165,6 +169,25 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
Some((Kind::SketchModify(k), i))
}
/// Sketch modify inputs accept only the sketch being edited. Origin axes remain valid
/// rectangular-pattern directions, while Hole continues using its separate POINTS mask.
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
let active = session.active_sketch?;
match hit {
Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } if *sketch != active => None,
_ => d.active_input()?.accepts(hit, |_, _| false),
}
}
/// Place a circular pattern centre in empty sketch space without creating a document point.
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
return false;
}
d.pick(session, Sel::Vertex { body: String::new(), point });
true
}
/// The rows after the inputs; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
let mut enter = false;
@@ -311,7 +334,7 @@ pub(crate) fn corner_hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Hit,
let Some(Kind::SketchModify(Mod::Corner { chamfer, .. })) = app.dialog.as_ref().map(|d| &d.kind) else { return None };
let sketch = app.session.active_sketch?;
let (at, id) = crate::sketch_tools::corner_point(app, proj, pos, !*chamfer)?;
let sel = Sel::SketchPoint { id: format!("{sketch}:{id}") };
let sel = Sel::SketchPoint { id: id.clone() };
Some((Hit::SketchPoint { sketch, id, at }, sel))
}
@@ -320,23 +343,28 @@ fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
/// A picked sketch point as the id its own sketch knows it by. Picking prefixes the sketch
/// ("`<sketch>:<point>`") so points of any visible sketch stay distinct; a sketch command
/// names the point alone.
fn point_id(sel: &Sel) -> Option<&str> {
match sel {
Sel::SketchPoint { id } => Some(id.split_once(':').map(|(_, p)| p).unwrap_or(id)),
_ => None,
/// A local point id. Qualified ids supplied by scripts or older selections are also accepted;
/// commands check that their sketch prefix identifies the sketch being edited.
pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) -> Option<&'a str> {
let Sel::SketchPoint { id } = sel else { return None };
let Some((owner, point)) = id.split_once(':') else { return Some(id) };
let active = session.active_sketch?;
// Custom local ids may themselves contain ':'. Preserve them before interpreting a
// legacy qualified selection, whose owner must match the sketch being edited.
if session.world_state().sketch(active).is_some_and(|sk| sk.sketch.point_index(id).is_some()) {
Some(id)
} else {
(owner.parse::<u64>().ok() == Some(active)).then_some(point)
}
}
/// The curves and lone points picked in input `i`, as the engine's `entities` list.
fn entity_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
items(inputs, i)
.iter()
.filter_map(|x| match x {
Sel::SketchCurve { id } => Some(id.clone()),
_ => point_id(x).map(str::to_string),
_ => point_id(session, x).map(str::to_string),
})
.collect()
}
@@ -412,20 +440,9 @@ fn length(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
}
Ok(v)
}
/// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
/// Corner point ids within the active sketch, including qualified script selections.
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
let active = app.session.active_sketch;
items(inputs, i)
.iter()
.filter_map(|x| match x {
Sel::SketchPoint { id } => match id.split_once(':') {
Some((sketch, point)) => (sketch.parse::<u64>().ok() == active).then(|| point.to_string()),
None => Some(id.clone()),
},
_ => None,
})
.collect()
items(inputs, i).iter().filter_map(|sel| point_id(&app.session, sel).map(str::to_string)).collect()
}
/// A corner size evaluated against the document's parameters, which every corner command needs
@@ -472,23 +489,25 @@ fn total_angle(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
/// 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)
fn picked_point(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Option<String> {
items(inputs, i).first().and_then(|sel| point_id(session, sel)).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.
/// The command a sketch modify dialog makes. Scale, Move, and patterns send evaluated
/// numbers. Offset and Fillet retain expressions in persistent driving dimensions.
pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
if inputs.iter().flat_map(|input| &input.items).any(|sel| matches!(sel, Sel::SketchPoint { .. }) && point_id(&app.session, sel).is_none()) {
return Err("Select points of the sketch being edited".into());
}
let (cmd, mut params) = match k {
Mod::Scale { factor: text } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to scale first".into());
}
let f = factor(app, text)?;
let mut p = json!({"entities": entities, "factor": f});
if let Some(base) = items(inputs, 1).first().and_then(point_id) {
if let Some(base) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
p["base"] = json!(base);
}
("sketch.scale", p)
@@ -499,20 +518,21 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Select the curves to offset first".into());
}
// Symmetric hides Flip, so a flip left over from another mode does not travel with it.
let d = signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?;
let mut params = json!({"curves": curves, "distance": d, "chain": chain});
signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?;
let mut params = json!({"curves": curves, "distance": distance.trim(), "flip": *flip && *mode != SYMMETRIC, "chain": chain});
if let Value::Object(m) = &mut params {
if *mode == SYMMETRIC {
m.insert("symmetric".into(), json!(true));
}
if *mode == TWO_SIDES {
m.insert("distance2".into(), json!(signed_length(app, "Distance 2", distance2, false)?.abs()));
let d2 = signed_length(app, "Distance 2", distance2, false)?;
m.insert("distance2".into(), json!(if d2 > 0.0 { distance2.trim().to_string() } else { format!("-({})", distance2.trim()) }));
}
}
("sketch.offset", params)
}
Mod::Mirror => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to mirror first".into());
}
@@ -529,7 +549,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
("sketch.mirror", json!({"entities": entities, "line": line}))
}
Mod::Move { x, y, angle, copy } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to move first".into());
}
@@ -540,13 +560,13 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Type a distance or an angle to move by".into());
}
let mut p = json!({"entities": entities, "translate": [dx, dy], "angle": deg, "copy": copy});
if let Some(center) = items(inputs, 1).first().and_then(point_id) {
if let Some(center) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
p["center"] = json!(center);
}
("sketch.move", p)
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
let entities = entity_ids(inputs, 0);
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
}
@@ -573,11 +593,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);
let entities = entity_ids(&app.session, 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 center = match items(inputs, 1).first() {
Some(Sel::Vertex { body, point }) if body.is_empty() => {
let plane =
app.session.world_state().sketch(app.session.active_sketch.ok_or("open a sketch first")?).ok_or("sketch is missing")?.plane;
let p = plane.to_local(*point);
json!([p.x, p.y])
}
_ => json!(picked_point(&app.session, 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());
+2
View File
@@ -71,6 +71,8 @@ pub struct Tokens {
pub manipulator: Color32,
/// New surface in a feature preview.
pub preview_add: Color32,
/// Proposed sketch geometry, distinct from blue selections.
pub preview_sketch: Color32,
/// Removed material in a feature preview (translucent).
pub preview_cut: Color32,
/// Navigation cube faces, outline and labels.
+25 -4
View File
@@ -2129,6 +2129,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some((hit, Some(sel))) = cand {
dialog_pick(app, &hit, sel);
} else if let Some(mut dialog) = app.dialog.take() {
let state = app.session.world_state();
if let Some(sketch) = app.session.active_sketch.and_then(|id| state.sketch(id)) {
let (origin, direction) = proj.ray(p);
if let Some(point) = sketch.plane.intersect_ray(origin, direction) {
crate::sketch_modify_ui::pick_position(&mut dialog, &app.session, point);
}
}
app.dialog = Some(dialog);
}
} else {
// Dimensions and constraint glyphs sit on top of the geometry.
@@ -2382,6 +2391,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
} else {
app.viewport.boxsel = None;
}
crate::preview::paint_sketches(app, &painter, &proj);
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
@@ -2543,12 +2553,19 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
let st = app.session.world_state();
let mut out: Vec<Sel> = Vec::new();
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
if app.dialog.is_none()
let sketch_dialog = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)));
let accepts = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept);
if (app.dialog.is_none() || sketch_dialog)
&& let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
{
// What the selection filter lets through (window: wholly inside; crossing: touching).
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
for (i, c) in ss.sketch.curves.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_curves")) {
if accepts
.is_some_and(|a| a & crate::selection::CURVES == 0 && (a & crate::selection::AXES == 0 || !matches!(c.kind, CurveKind::Line { .. })))
{
continue;
}
if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) {
continue;
}
@@ -2570,18 +2587,22 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) {
if i != 0 && solidcraft_engine::view::sketch_point_visible(&ss.sketch, i) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) {
if accepts.is_none_or(|a| a & crate::selection::SKETCH_POINTS != 0)
&& i != 0
&& solidcraft_engine::view::sketch_point_visible(&ss.sketch, i)
&& bx.takes(&to2(&[ss.plane.to_world(p.pos)]))
{
out.push(Sel::SketchPoint { id: p.id.clone() });
}
}
if !off("dimensions") {
if !sketch_dialog && !off("dimensions") {
for (id, r) in crate::dim_view::text_rects() {
if bx.takes(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]) {
out.push(Sel::SketchConstraint { id });
}
}
}
if !off("constraints") {
if !sketch_dialog && !off("constraints") {
for (id, c) in crate::sketch_tools::glyph_positions() {
if bx.takes(&[c]) {
out.push(Sel::SketchConstraint { id });
@@ -0,0 +1,25 @@
[
{"call":"ui.prefs","params":{"look_at_anim":false}},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-30,10],[-15,10]]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-15,10],[-15,25]]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[0,40]],"construction":true}}},
{"call":"ui.view","params":{"view":"top"}},
{"start":"sketch.mirror"},
{"expect":{"dialog":"SketchModify(Mirror)","tool":null}},
{"click":{"sketch":[-22.5,10]}},
{"click":{"sketch":[-15,17.5]}},
{"click":{"sketch":[-22.5,10]},"ctrl":true},
{"click":{"sketch":[-22.5,10]},"ctrl":true},
{"call":"ui.dialogInput","params":{"index":1}},
{"click":{"sketch":[0,20]}},
{"until":{"count":{"name":"sketch_preview_curves","value":2}}},
{"expect":{"sketch_curves":{"own":3}}},
{"click":{"handle":"dialog:clear:Mirror line"}},
{"click":{"sketch":[0,20]}},
{"until":{"count":{"name":"sketch_preview_curves","value":2}}},
{"click":{"handle":"dialog:OK"}},
{"expect":{"dialog":null,"sketch_curves":{"own":5},"errors":0}},
{"call":"engine.execute","params":{"command":"edit.undo","params":{}}},
{"expect":{"sketch_curves":{"own":3},"errors":0}}
]
+1 -1
View File
@@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 18, including the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
Current designs use format 19, including editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
+19
View File
@@ -226,3 +226,22 @@ remain valid in an inch design. Angular dimensions keep their angular units. Del
retain trailing zeros. Component/canvas distance controls and inspection, parts-library
and flat-pattern measurements follow design units too; parameter expressions and metric
standard names retain their explicit authored units.
### Reviewing mirror, pattern, and offset results
Mirror, rectangular/circular Pattern, and Offset show the active selection step in their
shared tool window. Click an input to change it; click a selected object again or Ctrl/Cmd-click
it to remove it. Each input has its own Clear button. Source inputs show a count, and single
references identify the chosen mirror line, direction, or centre. Box selection also works
while the window is open. Sketch modify tools take geometry from the sketch being edited.
Proposed sketch geometry appears **green** before confirmation. Change the inputs or values to
update the preview, then press **OK**. **Cancel** discards the preview. A circular pattern centre
can be an existing point or a position clicked in empty sketch space.
Offset distances are editable driving dimensions: double-click a dimension or change its model
parameter to resize the offset without moving its source curves. The selected side, distance,
and association survive save/reopen and undo/redo. Symmetric offsets share one driving distance;
Two Sides has an independent distance for each side. Disconnected selected curves or chains are
all offset. Breaking an association retains its current geometry and removes that association's
driver. Older offsets retain their saved fixed distances.
+1 -1
View File
@@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract;
`io::write_design_container` is the fallible container writer.
Packages have `container.json` with `{"format":"solidcraft-container/1"}` and
`document.json` with the versioned parametric document (currently `solidcraft/18`).
`document.json` with the versioned parametric document (currently `solidcraft/19`).
The independent versions let document migrations evolve without changing blob encodings.
Existing serialized document fields are retained automatically, without a second copy
of the document schema or a deep clone of embedded payloads.