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:
1 parent
9c51f9d433
commit
47a7f29485
29 files changed
+1107
-186
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¶m) } else { ¶m };
|
||||
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);
|
||||
|
||||
@@ -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(¶m)).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());
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¶ms).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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
]
|
||||
Reference in new issue
Block a user