Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)

Thanks @Karl-VonRichter.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-16 00:32:32 -04:00
1 parent 44968dbfe6
commit 371578a06f
36 files changed
+1394 -87

No files matched your search

+14
View File
@@ -762,6 +762,20 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a);
}
"sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a),
"pattern_flip" => {
// Opposing arrows show reversal, with a short reference tick between them.
pen.arrow((4.0, 7.0), (20.0, 7.0), a);
pen.arrow((20.0, 17.0), (4.0, 17.0), ink);
pen.line(&[(12.0, 10.0), (12.0, 14.0)], ink);
}
"pattern_center" => {
// An original on a reference axis, with one equally spaced copy on either side.
pen.line(&[(12.0, 3.0), (12.0, 21.0)], a);
pen.line(&[(4.0, 12.0), (20.0, 12.0)], ink);
for x in [4.0, 12.0, 20.0] {
pen.circle(x, 12.0, 2.4, fill, if x == 12.0 { a } else { ink });
}
}
"sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a),
"line_mid" => {
pen.line(&[(3.0, 19.0), (21.0, 5.0)], a);
+7
View File
@@ -4,6 +4,7 @@
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.",
"3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.",
"AI": "KI",
"Alignment": "Ausrichtung",
"Arc length": "Bogenlänge",
"Area": "Fläche",
"BROWSER": "BROWSER",
@@ -457,6 +458,11 @@
"nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)",
"nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.",
"nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)",
"pattern.alignment_2": "Ausrichtung 2",
"pattern.alignment_hint": "Die Anzahl enthält das Original. Die zentrierte Anordnung verteilt Kopien auf beide Seiten; bei gerader Anzahl legt die Richtungsumkehr die Seite mit der zusätzlichen Kopie fest.",
"pattern.direction_1": "Richtung 1",
"pattern.direction_2": "Richtung 2",
"pattern.flip_direction": "Richtung umkehren",
"render.background": "Szenenhintergrund",
"render.fit": "Produkt einpassen",
"render.image_cancel": "Rendering abbrechen",
@@ -874,6 +880,7 @@
"ui.heading_offset_plane": "ABSTANDSEBENE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETER",
"ui.heading_pattern": "MUSTER",
"ui.heading_pattern_on_path": "PFADMUSTER",
"ui.heading_pipe": "ROHR",
"ui.heading_plane_at_angle": "EBENE IM WINKEL",
+6
View File
@@ -671,6 +671,11 @@
"no interference": "no interference",
"no joint that moves yet": "no joint that moves yet",
"no picture": "no picture",
"pattern.alignment_2": "Alignment 2",
"pattern.alignment_hint": "Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
"pattern.direction_1": "Direction 1",
"pattern.direction_2": "Direction 2",
"pattern.flip_direction": "Flip direction",
"pick a hole to seat it, or OK places it at the origin": "pick a hole to seat it, or OK places it at the origin",
"pick one or two vertices, edges or faces": "pick one or two vertices, edges or faces",
"render.background": "Scene background",
@@ -1118,6 +1123,7 @@
"ui.heading_offset_plane": "OFFSET PLANE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETERS",
"ui.heading_pattern": "PATTERN",
"ui.heading_pattern_on_path": "PATTERN ON PATH",
"ui.heading_pipe": "PIPE",
"ui.heading_plane_at_angle": "PLANE AT ANGLE",
+6
View File
@@ -651,6 +651,11 @@
"no interference": "aucune interférence",
"no joint that moves yet": "aucune liaison mobile pour l'instant",
"no picture": "aucune image",
"pattern.alignment_2": "Alignement 2",
"pattern.alignment_hint": "La quantité inclut l’original. Le centrage répartit les copies de chaque côté ; si la quantité est paire, l’inversion choisit le côté qui reçoit la copie supplémentaire.",
"pattern.direction_1": "Direction 1",
"pattern.direction_2": "Direction 2",
"pattern.flip_direction": "Inverser la direction",
"pick a hole to seat it, or OK places it at the origin": "choisir un trou pour la loger, ou OK la place à l'origine",
"pick one or two vertices, edges or faces": "choisir un ou deux sommets, arêtes ou faces",
"render.background": "Arrière-plan de la scène",
@@ -944,6 +949,7 @@
"ui.heading_offset_plane": "PLAN DÉCALÉ",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMÈTRES",
"ui.heading_pattern": "RÉSEAU",
"ui.heading_pattern_on_path": "RÉSEAU SUR TRAJECTOIRE",
"ui.heading_pipe": "TUYAU",
"ui.heading_plane_at_angle": "PLAN INCLINÉ",
+6
View File
@@ -671,6 +671,11 @@
"no interference": "干渉なし",
"no joint that moves yet": "可動ジョイントがまだありません",
"no picture": "画像なし",
"pattern.alignment_2": "配置 2",
"pattern.alignment_hint": "個数には元の図形が含まれます。中央配置ではコピーを両側に配置し、反転で偶数個の場合に多く配置する側を切り替えます。",
"pattern.direction_1": "方向 1",
"pattern.direction_2": "方向 2",
"pattern.flip_direction": "方向を反転",
"pick a hole to seat it, or OK places it at the origin": "配置先の穴を選択するか、[OK]を押すと原点に配置します。",
"pick one or two vertices, edges or faces": "1つまたは2つの頂点、エッジ、面を選択",
"render.background": "シーンの背景",
@@ -1118,6 +1123,7 @@
"ui.heading_offset_plane": "オフセット平面",
"ui.heading_ok": "OK",
"ui.heading_parameters": "パラメータ",
"ui.heading_pattern": "パターン",
"ui.heading_pattern_on_path": "パス上のパターン",
"ui.heading_pipe": "パイプ",
"ui.heading_plane_at_angle": "傾斜平面",
+7
View File
@@ -4,6 +4,7 @@
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.",
"3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.",
"AI": "AI",
"Alignment": "정렬",
"Arc length": "호 길이",
"Area": "면적",
"BROWSER": "브라우저",
@@ -457,6 +458,11 @@
"nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)",
"nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.",
"nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)",
"pattern.alignment_2": "정렬 2",
"pattern.alignment_hint": "개수에는 원본이 포함됩니다. 가운데 정렬은 복사본을 양쪽에 배치하며, 개수가 짝수이면 방향 반전으로 복사본이 하나 더 배치되는 쪽을 선택합니다.",
"pattern.direction_1": "방향 1",
"pattern.direction_2": "방향 2",
"pattern.flip_direction": "방향 반전",
"render.background": "장면 배경",
"render.fit": "제품에 맞추기",
"render.image_cancel": "렌더링 취소",
@@ -874,6 +880,7 @@
"ui.heading_offset_plane": "오프셋 평면",
"ui.heading_ok": "확인",
"ui.heading_parameters": "매개변수",
"ui.heading_pattern": "패턴",
"ui.heading_pattern_on_path": "경로 패턴",
"ui.heading_pipe": "파이프",
"ui.heading_plane_at_angle": "각도 평면",
+6
View File
@@ -613,6 +613,11 @@
"no interference": "нет интерференции",
"no joint that moves yet": "пока нет подвижных сопряжений",
"no picture": "нет картинки",
"pattern.alignment_2": "Выравнивание 2",
"pattern.alignment_hint": "Количество включает оригинал. Центрирование распределяет копии по обе стороны; при чётном количестве смена направления выбирает сторону с дополнительной копией.",
"pattern.direction_1": "Направление 1",
"pattern.direction_2": "Направление 2",
"pattern.flip_direction": "Изменить направление",
"pick a hole to seat it, or OK places it at the origin": "выберите отверстие для установки, или OK разместит в начале координат",
"pick one or two vertices, edges or faces": "выберите одну-две вершины, ребра или грани",
"render.background": "Фон сцены",
@@ -896,6 +901,7 @@
"ui.heading_offset_plane": "СМЕЩЁННАЯ ПЛОСКОСТЬ",
"ui.heading_ok": "OK",
"ui.heading_parameters": "ПАРАМЕТРЫ",
"ui.heading_pattern": "МАССИВ",
"ui.heading_pattern_on_path": "МАССИВ ПО ТРАЕКТОРИИ",
"ui.heading_pipe": "ТРУБА",
"ui.heading_plane_at_angle": "ПЛОСКОСТЬ ПОД УГЛОМ",
+6
View File
@@ -628,6 +628,11 @@
"no interference": "girişim yok",
"no joint that moves yet": "henüz hareket eden bir bağlantı yok",
"no picture": "resim yok",
"pattern.alignment_2": "Hizalama 2",
"pattern.alignment_hint": "Adet, orijinali içerir. Ortala, kopyaları iki yana dağıtır; çift adetlerde yönü ters çevirme, fazladan kopyanın hangi tarafta olacağını belirler.",
"pattern.direction_1": "Yön 1",
"pattern.direction_2": "Yön 2",
"pattern.flip_direction": "Yönü ters çevir",
"pick a hole to seat it, or OK places it at the origin": "yerleştirmek için bir delik seçin veya OK ile orijine yerleştirin",
"pick one or two vertices, edges or faces": "bir veya iki köşe noktası, kenar ya da yüz seçin",
"render.background": "Sahne arka planı",
@@ -1056,6 +1061,7 @@
"ui.heading_offset_plane": "OFSET DÜZLEM",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETRELER",
"ui.heading_pattern": "DESEN",
"ui.heading_pattern_on_path": "YOL ÜZERİNDE DESEN",
"ui.heading_pipe": "BORU",
"ui.heading_plane_at_angle": "AÇILI DÜZLEM",
+6
View File
@@ -597,6 +597,11 @@
"no interference": "немає перетинів",
"no joint that moves yet": "ще немає рухомого з'єднання",
"no picture": "немає зображення",
"pattern.alignment_2": "Вирівнювання 2",
"pattern.alignment_hint": "Кількість включає оригінал. Центрування розподіляє копії з обох боків; за парної кількості зміна напрямку визначає бік із додатковою копією.",
"pattern.direction_1": "Напрямок 1",
"pattern.direction_2": "Напрямок 2",
"pattern.flip_direction": "Змінити напрямок",
"pick a hole to seat it, or OK places it at the origin": "виберіть отвір для посадки, або OK розмістить його в початку координат",
"pick one or two vertices, edges or faces": "виберіть одну-дві вершини, ребра або грані",
"settings.measure_axis_distances": "Відстані X/Y/Z",
@@ -796,6 +801,7 @@
"ui.heading_offset_plane": "ЗСУНУТА ПЛОЩИНА",
"ui.heading_ok": "OK",
"ui.heading_parameters": "ПАРАМЕТРИ",
"ui.heading_pattern": "МАСИВ",
"ui.heading_pattern_on_path": "МАСИВ НА ТРАЄКТОРІЇ",
"ui.heading_pipe": "ТРУБА",
"ui.heading_plane_at_angle": "ПЛОЩИНА ПІД КУТОМ",
+6
View File
@@ -671,6 +671,11 @@
"no interference": "无干涉",
"no joint that moves yet": "尚无运动接头",
"no picture": "无图片",
"pattern.alignment_2": "对齐 2",
"pattern.alignment_hint": "数量包含原始图形。居中时副本分布在两侧;数量为偶数时,反转可切换多一个副本的一侧。",
"pattern.direction_1": "方向 1",
"pattern.direction_2": "方向 2",
"pattern.flip_direction": "反转方向",
"pick a hole to seat it, or OK places it at the origin": "选择一个孔放置,或单击确定放置在原点",
"pick one or two vertices, edges or faces": "选择一个或两个顶点、边或面",
"render.background": "场景背景",
@@ -1118,6 +1123,7 @@
"ui.heading_offset_plane": "偏移平面",
"ui.heading_ok": "确定",
"ui.heading_parameters": "参数",
"ui.heading_pattern": "阵列",
"ui.heading_pattern_on_path": "路径阵列",
"ui.heading_pipe": "管道",
"ui.heading_plane_at_angle": "角度平面",
@@ -0,0 +1,5 @@
//! Sketch linear patterns save two independent live direction-line references and each
//! direction's alignment and reversal. Even centered totals keep the original fixed and use
//! reversal to choose which side receives the extra copy. Older patterns retain their fixed
//! vectors and positive-direction placement through defaulted fields, so no rewrite is needed.
//! The last release refuses this pending format instead of silently losing the references.
+8 -5
View File
@@ -80,20 +80,20 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "CREATE")
.at("SKETCH", "PATTERN")
.icon("sketch_mirror")
.enabled(in_sketch)
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
.at("SKETCH", "CREATE")
.at("SKETCH", "PATTERN")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360). Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.at("SKETCH", "PATTERN")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction). Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
];
const TOL: f64 = 1e-7;
@@ -1700,6 +1700,9 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
for (o, n) in &map {
if o != n {
cons.push(add_c(sk, ConstraintKind::Symmetric { p: *o, q: *n, l })?);
} else if !sk.curves.get(l).is_some_and(|curve| curve.kind.point_ids().contains(o)) {
// A shared endpoint must stay on the mirror line as the source changes.
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: *o, c: l })?);
}
}
for (o, n) in cs.iter().zip(&made) {
+121 -11
View File
@@ -1,6 +1,6 @@
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
use solidcraft_sketch::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind};
use super::*;
@@ -23,9 +23,24 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
return Err(bad(cmd, "use the existing pattern's type"));
}
let defaults = match &old.kind {
SketchPatternKind::Circular { center, count, angle } => json!({"entities":old.entities,"center":center,"count":count,"angle":angle}),
SketchPatternKind::Rectangular { dir, count, spacing, count2, spacing2 } => {
json!({"entities":old.entities,"dir":dir,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2})
SketchPatternKind::Circular { center, count, angle, alignment, reversed } => {
json!({"entities":old.entities,"center":center,"count":count,"angle":angle,"alignment":alignment,"reversed":reversed})
}
SketchPatternKind::Rectangular {
dir,
dir2,
direction,
direction2,
count,
spacing,
count2,
spacing2,
alignment,
alignment2,
reversed,
reversed2,
} => {
json!({"entities":old.entities,"dir":dir,"dir2":dir2,"direction":direction,"direction2":direction2,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2,"alignment":alignment,"alignment2":alignment2,"reversed":reversed,"reversed2":reversed2})
}
};
if let (Some(params), Some(defaults)) = (params.as_object_mut(), defaults.as_object()) {
@@ -78,10 +93,12 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
let full = (total.abs() - 360.0).abs() < 1e-9;
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
let alignment = alignment_param(&params, "alignment", cmd)?;
let reversed = reversed_param(&params, "reversed", cmd)?;
(
SketchPatternKind::Circular { center: id, count: n, angle: total },
SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed },
Some(center),
(1..n).map(|i| ([i, 0], (step * i as f64).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
(1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
)
} else {
let spacing = num(&params, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
@@ -98,14 +115,49 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
Some(value) => vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir` must be a finite nonzero vector"))?,
None => Vec2::X,
};
let dir2 = match params.get("dir2") {
Some(value) if !value.is_null() => {
Some(vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir2` must be a finite nonzero vector"))?)
}
_ => None,
};
let direction = direction_ref(sk, &params, "direction", cmd)?;
let direction2 = direction_ref(sk, &params, "direction2", cmd)?;
let alignment = alignment_param(&params, "alignment", cmd)?;
let alignment2 = alignment_param(&params, "alignment2", cmd)?;
let reversed = reversed_param(&params, "reversed", cmd)?;
let reversed2 = reversed_param(&params, "reversed2", cmd)?;
let first = line_direction(sk, direction.as_deref()).unwrap_or(dir);
let second = line_direction(sk, direction2.as_deref()).or(dir2).unwrap_or_else(|| first.perp());
let slots = (0..n)
.flat_map(|i| {
(0..count2).filter_map(move |j| {
(i != 0 || j != 0).then_some(([i, j], 0.0, dir * (spacing * i as f64) + dir.perp() * (spacing2 * j as f64)))
(i != 0 || j != 0).then_some((
[i, j],
0.0,
first * (spacing * alignment.multiplier(i, reversed)) + second * (spacing2 * alignment2.multiplier(j, reversed2)),
))
})
})
.collect();
(SketchPatternKind::Rectangular { dir, count: n, spacing, count2, spacing2 }, None, slots)
(
SketchPatternKind::Rectangular {
dir,
dir2,
direction,
direction2,
count: n,
spacing,
count2,
spacing2,
alignment,
alignment2,
reversed,
reversed2,
},
None,
slots,
)
};
let id = match &old {
Some(old) => old.id.clone(),
@@ -126,20 +178,28 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
if center_id.is_some() && center.is_none() {
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
}
if !circular {
// A direction can have belonged to an instance retired above. Resolve it again
// before creating relations so an edit never silently substitutes a vector.
direction_ref(sk, &params, "direction", cmd)?;
direction_ref(sk, &params, "direction2", cmd)?;
}
let mut curves = Vec::new();
let mut constraints = Vec::new();
for (index, angle, offset) in slots {
let (base_offset, directions) = linear_displacement(sk, &kind, index, offset);
let c = center.and_then(|i| sk.point(i)).unwrap_or(Vec2::ZERO);
let transform = |p| rotate(c, angle)(p) + offset;
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
let instance = existing.remove(i);
for cid in &instance.constraints {
if let Some(constraint) = sk.constraints.iter_mut().find(|constraint| constraint.id == *cid)
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, .. } = &mut constraint.kind
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
{
*slot = center;
*a = angle;
*off = offset;
*off = base_offset;
*dirs = directions;
}
}
for (seed, copy) in &instance.points {
@@ -155,7 +215,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
let mut cons = Vec::new();
for (p, q) in &map {
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset })?);
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions })?);
}
for (a, b) in cs.iter().zip(&made) {
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
@@ -188,3 +248,53 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
}
Ok(out)
}
fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(id)) if line_direction(sk, Some(id)).is_some() => Ok(Some(id.clone())),
_ => Err(bad(cmd, format!("`{key}` must reference a nondegenerate sketch line"))),
}
}
fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<PatternAlignment> {
p.get(key).map_or(Ok(PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be left, center or right")))
})
}
fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be true or false"))))
}
fn line_direction(sk: &Sketch, id: Option<&str>) -> Option<Vec2> {
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
(b - a).normalized()
}
/// Keep sampled vector directions compatible with older designs, while picked lines are
/// solver references. The default second direction follows the first line's perpendicular.
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
else {
return (fallback, [None; 2]);
};
let mut offset = Vec2::ZERO;
let mut directions = [None; 2];
let d1 = *spacing * alignment.multiplier(i, *reversed);
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
} else {
offset += *dir * d1;
if dir2.is_none() && direction2.is_none() {
offset += dir.perp() * d2;
}
}
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
directions[1] = Some((l, Vec2::new(d2, 0.0)));
} else if let Some(dir2) = dir2 {
offset += *dir2 * d2;
}
(offset, directions)
}
@@ -4,6 +4,329 @@ fn point(sk: &solidcraft_sketch::Sketch, id: &str) -> Vec2 {
sk.point(sk.resolve_point(id).unwrap()).unwrap()
}
#[test]
fn circular_centered_six_holes_span_ninety_degrees_and_flip_about_the_original() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":6,"angle":90,"alignment":"center"}));
let copies = ids(&out["curves"]);
let check = |s: &Session, angles: &[f64], seed: Vec2, center: Vec2| {
assert!(point(&sketch(s), "seed.center").dist(seed) < 1e-6);
for (id, angle) in copies.iter().zip(angles) {
let (sin, cos) = angle.to_radians().sin_cos();
let d = seed - center;
let expected = center + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos);
assert!(point(&sketch(s), &format!("{id}.center")).dist(expected) < 1e-6, "angle {angle}");
}
check_relations(s);
};
check(&s, &[18., -18., 36., -36., 54.], Vec2::new(20., 0.), Vec2::ZERO);
let before = s.doc.to_json();
let flipped = run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"reversed":true}));
assert_eq!(ids(&flipped["curves"]), copies);
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(20., 0.), Vec2::ZERO);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
s.refresh();
// Omitting placement settings must retain them after reopening.
run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"angle":90}));
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]]}));
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[20,0],"to":[25,2]}));
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::ZERO);
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]],"fixed":false}));
run(&mut s, "sketch.drag", json!({"entity":out["center"],"from":[0,0],"to":[2,3]}));
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::new(2., 3.));
}
#[test]
fn circular_alignment_handles_odd_arcs_one_sided_rotation_and_full_turns() {
for (count, angle, alignment, reversed, expected) in [
(5, 90., "center", false, vec![22.5_f64, -22.5, 45., -45.]),
(3, 90., "right", false, vec![45., 90.]),
(3, 90., "right", true, vec![-45., -90.]),
(3, 90., "left", false, vec![-45., -90.]),
(3, -90., "center", true, vec![45., -45.]),
(6, 360., "center", false, vec![60., -60., 120., -120., 180.]),
(6, 360., "center", true, vec![-60., 60., -120., 120., -180.]),
] {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
let out = run(
&mut s,
"sketch.pattern.circular",
json!({"entities":["seed"],"center":[0,0],"count":count,"angle":angle,"alignment":alignment,"reversed":reversed}),
);
let mut positions = vec![Vec2::new(20., 0.)];
for (id, a) in ids(&out["curves"]).iter().zip(expected) {
let pos = point(&sketch(&s), &format!("{id}.center"));
let (sin, cos) = a.to_radians().sin_cos();
assert!(pos.dist(Vec2::new(20. * cos, 20. * sin)) < 1e-6);
assert!(positions.iter().all(|p| pos.dist(*p) > 1.));
positions.push(pos);
}
assert_eq!(positions.len(), count);
check_relations(&s);
}
}
#[test]
fn circular_invalid_alignment_and_flip_roll_back_and_old_saved_defaults_still_load() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":3,"angle":90}));
let before = s.doc.to_json();
for extra in [json!({"reversed":"true"}), json!({"reversed":null}), json!({"alignment":"unknown"}), json!({"alignment":null})] {
let mut params = json!({"pattern":out["pattern"]});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(s.execute("sketch.pattern.circular", &params).is_err(), "{extra}");
assert_eq!(s.doc.to_json(), before);
}
let mut saved: Value = serde_json::from_str(&before).unwrap();
let kind = saved["features"][0]["sketch"]["patterns"][0]["kind"].as_object_mut().unwrap();
kind.remove("alignment");
kind.remove("reversed");
let mut reopened =
Session { doc: solidcraft_doc::format::read(&saved.to_string()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
reopened.refresh();
run(&mut reopened, "sketch.pattern.circular", json!({"pattern":out["pattern"],"count":3}));
assert!(point(&sketch(&reopened), &format!("{}.center", out["curves"][1].as_str().unwrap())).dist(Vec2::new(0., 20.)) < 1e-6);
}
#[test]
fn centered_two_direction_grid_keeps_original_fixed_and_follows_live_guides_after_reopen() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide1"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-30],[6,-22]],"ids":["guide2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide1","guide2"]}));
let out = run(
&mut s,
"sketch.pattern.rectangular",
json!({
"entities":["seed"],"direction":"guide1","direction2":"guide2",
"count":3,"spacing":20,"count2":3,"spacing2":10,"alignment":"center","alignment2":"center"
}),
);
let expected = [(11., 13.), (-1., -3.), (25., 5.), (31., 13.), (19., -3.), (-15., 5.), (-9., 13.), (-21., -3.)];
for (curve, (x, y)) in out["curves"].as_array().unwrap().iter().zip(expected) {
assert!(point(&sketch(&s), &format!("{}.center", curve.as_str().unwrap())).dist(Vec2::new(x, y)) < 1e-6);
}
assert!(point(&sketch(&s), "seed.center").dist(Vec2::new(5., 5.)) < 1e-6);
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[5,5],"to":[7,8]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2"],"fixed":false}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2.start"]}));
run(&mut s, "sketch.drag", json!({"entity":"guide2.end","from":[6,-22],"to":[8,-24]}));
check_relations(&s);
let far = out["curves"][7].as_str().unwrap();
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-21., 2.)) < 1e-6);
let saved = s.doc.to_json();
let mut s = Session { doc: solidcraft_doc::format::read(&saved).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
s.refresh();
let before = s.doc.to_json();
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"alignment":"left","alignment2":"right","count":4}));
assert_eq!(edited["curves"][7], far);
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-17., 20.)) < 1e-6);
check_relations(&s);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn left_and_right_alignments_are_independent_and_invalid_alignment_values_roll_back() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
let out = run(
&mut s,
"sketch.pattern.rectangular",
json!({
"entities":["seed"],"count":3,"spacing":20,"count2":2,"spacing2":10,"alignment":"left","alignment2":"right"
}),
);
let far = out["curves"][4].as_str().unwrap();
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-35., 15.)) < 1e-6);
run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"alignment":"right","alignment2":"left"}));
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(45., -5.)) < 1e-6);
let before = s.doc.to_json();
for extra in [json!({"reversed":"true"}), json!({"reversed2":null}), json!({"alignment":"unknown"}), json!({"alignment2":null})] {
let mut params = json!({"pattern":out["pattern"]});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(s.execute("sketch.pattern.rectangular", &params).is_err(), "{extra}");
assert_eq!(s.doc.to_json(), before);
}
let mut broken: Value = serde_json::from_str(&before).unwrap();
broken["features"][0]["sketch"]["patterns"][0]["kind"]["reversed2"] = json!("true");
assert!(solidcraft_doc::format::read(&broken.to_string()).is_err());
}
#[test]
fn even_centered_totals_flip_the_extra_side_independently_and_remain_associative() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide1"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-30],[6,-22]],"ids":["guide2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide1","guide2"]}));
let out = run(
&mut s,
"sketch.pattern.rectangular",
json!({
"entities":["seed"],"direction":"guide1","direction2":"guide2",
"count":4,"spacing":20,"count2":2,"spacing2":10,"alignment":"center","alignment2":"center"
}),
);
let copy_ids = ids(&out["curves"]);
let expected = [(11., 13.), (25., 5.), (31., 13.), (-15., 5.), (-9., 13.), (45., 5.), (51., 13.)];
for (id, (x, y)) in copy_ids.iter().zip(expected) {
assert!(point(&sketch(&s), &format!("{id}.center")).dist(Vec2::new(x, y)) < 1e-6);
}
let before = s.doc.to_json();
let flipped = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"reversed":true}));
assert_eq!(ids(&flipped["curves"]), copy_ids);
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-29., 13.)) < 1e-6);
run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"reversed2":true}));
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-41., -3.)) < 1e-6);
assert!(point(&sketch(&s), "seed.center").dist(Vec2::new(5., 5.)) < 1e-6);
check_relations(&s);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
s.refresh();
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[5,5],"to":[7,8]}));
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-39., 0.)) < 1e-6);
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2"],"fixed":false}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2.start"]}));
run(&mut s, "sketch.drag", json!({"entity":"guide2.end","from":[6,-22],"to":[8,-24]}));
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-41., 2.)) < 1e-6);
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"spacing":10}));
assert_eq!(ids(&edited["curves"]), copy_ids);
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-21., 2.)) < 1e-6);
check_relations(&s);
}
#[test]
fn two_picked_pattern_lines_remain_live_through_drag_reopen_and_edit() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,5],[15,5]],"ids":["seed"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed","dir1.start","dir2.start"]}));
let out = run(
&mut s,
"sketch.pattern.rectangular",
json!({"entities":["seed"],"direction":"dir1","direction2":"dir2","count":3,"spacing":20,"count2":2,"spacing2":10}),
);
let copy = out["curves"][0].as_str().unwrap();
let far = out["curves"][4].as_str().unwrap();
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(16.0, 13.0)) < 1e-6);
assert!(point(&sketch(&s), &format!("{far}.start")).dist(Vec2::new(56.0, 13.0)) < 1e-6);
run(&mut s, "sketch.drag", json!({"entity":"dir2.end","from":[6,-32],"to":[8,-34]}));
check_relations(&s);
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(18.0, 11.0)) < 1e-6);
let saved = s.doc.to_json();
let mut s = Session { doc: solidcraft_doc::format::read(&saved).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
s.refresh();
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"count":4,"spacing2":-10}));
assert_eq!(edited["curves"][0], copy);
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(2.0, -1.0)) < 1e-6);
run(&mut s, "sketch.constraint.fix", json!({"entities":["dir2"]}));
run(&mut s, "sketch.drag", json!({"entity":"dir1.end","from":[10,-20],"to":[0,-10]}));
check_relations(&s);
assert!(point(&sketch(&s), &format!("{far}.start")).dist(Vec2::new(2.0, 39.0)) < 1e-6);
let before = s.doc.to_json();
run(&mut s, "sketch.delete", json!({"entities":["dir2"]}));
assert!(sketch(&s).patterns.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn mirror_and_both_patterns_keep_selected_line_chains_driven_by_the_source() {
for mode in [0, 1, 2] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[15,0],[15,5]],"ids":["l1","l2"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-20],[0,20]],"ids":["axis"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["axis"]}));
let (cmd, params) = match mode {
0 => ("sketch.mirror", json!({"entities":["l1","l2"],"line":"axis"})),
1 => (
"sketch.pattern.rectangular",
json!({"entities":["l1","l2"],"dir":[1,0],"dir2":[0.6,0.8],"count":2,"spacing":30,"count2":2,"spacing2":10}),
),
_ => ("sketch.pattern.circular", json!({"entities":["l1","l2"],"center":"origin","count":4})),
};
let out = run(&mut s, cmd, params);
run(&mut s, "sketch.drag", json!({"entity":"l1.end","from":[15,0],"to":[18,2]}));
check_relations(&s);
let sk = sketch(&s);
let original = point(&sk, "l1.end");
assert!(original.dist(Vec2::new(18.0, 2.0)) < 1e-6);
let expected = match mode {
0 => Vec2::new(-original.x, original.y),
1 => original + Vec2::new(6.0, 8.0),
_ => Vec2::new(-original.y, original.x),
};
let a = out["curves"][0].as_str().unwrap();
let b = out["curves"][1].as_str().unwrap();
// Reflection reverses each curve's traversal; the shared corner is still one point.
let (end, start) = if mode == 0 { ("start", "end") } else { ("end", "start") };
assert!(point(&sk, &format!("{a}.{end}")).dist(expected) < 1e-6, "{cmd}");
assert_eq!(sk.resolve_point(&format!("{a}.{end}")), sk.resolve_point(&format!("{b}.{start}")));
}
}
#[test]
fn malformed_two_direction_patterns_roll_back_and_saved_references_are_validated() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["seed"]}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2,"id":"circle"}));
let before = s.doc.to_json();
for extra in [
json!({"dir2":[0,0]}),
json!({"dir2":[1e308,1e308]}),
json!({"direction2":"missing"}),
json!({"direction":"circle"}),
json!({"direction2":42}),
] {
let mut params = json!({"entities":["seed"],"count":2,"spacing":10,"count2":2});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(s.execute("sketch.pattern.rectangular", &params).is_err(), "{extra}");
assert_eq!(s.doc.to_json(), before);
}
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"direction":"seed","count":2,"spacing":10}));
let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
for invalid in [json!([[usize::MAX, [10, 0]], null]), json!([[0, [1e308, 0]], null])] {
let mut broken = saved.clone();
let constraint =
broken["features"][0]["sketch"]["constraints"].as_array_mut().unwrap().iter_mut().find(|c| c["type"] == "pattern_point").unwrap();
constraint["directions"] = invalid;
assert!(solidcraft_doc::format::read(&broken.to_string()).is_err());
}
}
#[test]
fn mirrored_chain_endpoints_on_the_axis_remain_on_it_during_source_edits() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,5],[10,5],[10,15],[0,15]],"ids":["a","b","c"]}));
run(&mut s, "sketch.line", json!({"points":[[0,-10],[0,30]],"ids":["axis"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["axis"]}));
let out = run(&mut s, "sketch.mirror", json!({"entities":["a","b","c"],"line":"axis"}));
let copy = out["curves"][0].as_str().unwrap();
assert_eq!(sketch(&s).resolve_point("a.start"), sketch(&s).resolve_point(&format!("{copy}.end")));
run(&mut s, "sketch.drag", json!({"entity":"a.start","from":[0,5],"to":[4,8]}));
assert!(point(&sketch(&s), "a.start").x.abs() < 1e-6);
run(&mut s, "sketch.drag", json!({"entity":"b.start","from":[10,5],"to":[13,8]}));
let original = point(&sketch(&s), "b.start");
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(-original.x, original.y)) < 1e-6);
check_relations(&s);
}
fn check_relations(s: &Session) {
let sk = sketch(s);
for (i, constraint) in sk.constraints.iter().enumerate() {
@@ -11,6 +334,38 @@ fn check_relations(s: &Session) {
}
}
#[test]
fn default_second_direction_follows_the_first_line_and_legacy_vectors_keep_their_grid() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed","guide.start"]}));
let out =
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"direction":"guide","count":2,"spacing":10,"count2":2,"spacing2":20}));
run(&mut s, "sketch.drag", json!({"entity":"guide.end","from":[10,-10],"to":[0,0]}));
let far = out["curves"][2].as_str().unwrap();
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-15.0, 15.0)) < 1e-6);
check_relations(&s);
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"dir":[0,1],"count":2,"spacing":10,"count2":2,"spacing2":20}));
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
saved["format"] = json!("solidcraft/21");
let sk = &mut saved["features"][0]["sketch"];
for c in sk["constraints"].as_array_mut().unwrap() {
c.as_object_mut().unwrap().remove("directions");
}
let settings = sk["patterns"][0]["kind"].as_object_mut().unwrap();
for key in ["dir2", "direction", "direction2", "alignment", "alignment2", "reversed", "reversed2"] {
settings.remove(key);
}
let doc = solidcraft_doc::format::read(&saved.to_string()).unwrap();
let sk = doc.sketch(s.active_sketch.unwrap()).unwrap();
assert!(point(sk, "c3.center").dist(Vec2::new(-15.0, 15.0)) < 1e-6);
assert_eq!(doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
}
#[test]
fn circular_pattern_keeps_seed_copy_and_center_related_during_drags() {
let mut s = new_sketch();
+1 -1
View File
@@ -52,7 +52,7 @@ pub use model::{
};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use pattern::{PatternInstance, SketchPattern, SketchPatternKind};
pub use pattern::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind};
pub use periodic::periodic_beziers;
pub use profiles::{
CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves,
+17 -3
View File
@@ -189,6 +189,9 @@ pub enum ConstraintKind {
center: Option<usize>,
angle: f64,
offset: Vec2,
/// Displacements along and perpendicular to live sketch lines (at most two).
#[serde(default)]
directions: [Option<(usize, Vec2)>; 2],
},
/// The independent radius of a patterned circle or ellipse follows its seed.
PatternRadius {
@@ -423,7 +426,9 @@ impl ConstraintKind {
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
PatternPoint { p, q, center, .. } => (vec![p, q].into_iter().chain(center).collect(), vec![]),
PatternPoint { p, q, center, directions, .. } => {
(vec![p, q].into_iter().chain(center).collect(), directions.into_iter().flatten().map(|(l, _)| l).collect())
}
PatternRadius { a, b } => (vec![], vec![a, b]),
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]),
@@ -458,12 +463,15 @@ impl ConstraintKind {
None => ok2 = false,
};
match self {
PatternPoint { p, q, center, .. } => {
PatternPoint { p, q, center, directions, .. } => {
fp(p);
fp(q);
if let Some(c) = center {
fp(c);
}
for (l, _) in directions.iter_mut().flatten() {
fc(l);
}
}
PatternRadius { a, b } => {
fc(a);
@@ -848,7 +856,13 @@ impl Sketch {
let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) };
let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) };
match *k {
PatternPoint { p, q, angle, offset, .. } => {
PatternPoint { p, q, angle, offset, directions, .. } => {
for (l, distance) in directions.into_iter().flatten() {
need_line(l)?;
if !distance.x.is_finite() || !distance.y.is_finite() || distance.x.abs() > MAX_COORD || distance.y.abs() > MAX_COORD {
return Err(SketchError::Invalid("invalid pattern direction displacement".into()));
}
}
if p == q
|| !angle.is_finite()
|| angle.abs() > std::f64::consts::TAU
+71 -5
View File
@@ -5,11 +5,62 @@ use solidcraft_geom::Vec2;
use crate::{CurveKind, Sketch, SketchError};
/// Placement relative to the original, along a pattern direction's oriented line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatternAlignment {
Left,
Center,
#[default]
Right,
}
impl PatternAlignment {
/// Slot zero is always the original. Center alternates sides to retain stable copy ids.
pub fn multiplier(self, index: usize, reversed: bool) -> f64 {
let value = match self {
Self::Left => -(index as f64),
Self::Right => index as f64,
Self::Center if index.is_multiple_of(2) => -((index / 2) as f64),
Self::Center => index.div_ceil(2) as f64,
};
if reversed { -value } else { value }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SketchPatternKind {
Circular { center: String, count: usize, angle: f64 },
Rectangular { dir: Vec2, count: usize, spacing: f64, count2: usize, spacing2: f64 },
Circular {
center: String,
count: usize,
angle: f64,
#[serde(default)]
alignment: PatternAlignment,
#[serde(default)]
reversed: bool,
},
Rectangular {
dir: Vec2,
#[serde(default)]
dir2: Option<Vec2>,
#[serde(default)]
direction: Option<String>,
#[serde(default)]
direction2: Option<String>,
count: usize,
spacing: f64,
count2: usize,
spacing2: f64,
#[serde(default)]
alignment: PatternAlignment,
#[serde(default)]
alignment2: PatternAlignment,
#[serde(default)]
reversed: bool,
#[serde(default)]
reversed2: bool,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -37,6 +88,8 @@ impl SketchPattern {
.iter()
.any(|instance| instance.curves.iter().any(|id| id == entity) || instance.points.iter().any(|(_, id)| id == entity))
|| matches!(&self.kind, SketchPatternKind::Circular { center, .. } if center == entity)
|| matches!(&self.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
if direction.as_deref() == Some(entity) || direction2.as_deref() == Some(entity))
}
}
@@ -53,7 +106,14 @@ impl Sketch {
continue;
};
match kind {
crate::ConstraintKind::PatternPoint { p, q, center, angle, offset } => {
crate::ConstraintKind::PatternPoint { p, q, center, angle, mut offset, directions } => {
for (l, distance) in directions.into_iter().flatten() {
if let Some(crate::Shape::Line { a, b }) = self.shape(l)
&& let Some(u) = (b - a).normalized()
{
offset += u * distance.x + u.perp() * distance.y;
}
}
let Some(seed) = self.point(p) else { continue };
let c = center.and_then(|c| self.point(c)).unwrap_or(Vec2::ZERO);
let d = seed - c;
@@ -95,6 +155,8 @@ impl Sketch {
.filter(|pattern| {
pattern.entities.iter().any(|id| !curves.contains(id.as_str()) && !points.contains(id.as_str()))
|| matches!(&pattern.kind, SketchPatternKind::Circular { center, .. } if !points.contains(center.as_str()))
|| matches!(&pattern.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
if direction.iter().chain(direction2).any(|id| !curves.contains(id.as_str())))
|| pattern.instances.iter().any(|instance| {
instance.curves.iter().any(|id| !curves.contains(id.as_str()))
|| instance.points.iter().any(|(p, q)| !points.contains(p.as_str()) || !points.contains(q.as_str()))
@@ -165,10 +227,10 @@ impl Sketch {
let valid_count = |n| (1..=500).contains(&n);
let finite = |v: f64| v.is_finite() && v.abs() <= crate::MAX_COORD;
let valid = match &pattern.kind {
SketchPatternKind::Circular { count, angle, center } => {
SketchPatternKind::Circular { count, angle, center, .. } => {
valid_count(*count) && angle.is_finite() && angle.abs() > 1e-9 && angle.abs() <= 360.0 && self.resolve_point(center).is_some()
}
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir } => {
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir, dir2, direction, direction2, .. } => {
valid_count(*count)
&& valid_count(*count2)
&& count.checked_mul(*count2).is_some_and(|n| n <= 500)
@@ -177,6 +239,10 @@ impl Sketch {
&& finite(dir.x)
&& finite(dir.y)
&& (dir.len() - 1.0).abs() < 1e-6
&& dir2.is_none_or(|d| finite(d.x) && finite(d.y) && (d.len() - 1.0).abs() < 1e-6)
&& direction.iter().chain(direction2).all(|id| {
self.curve_index(id).is_some_and(|l| matches!(self.curves.get(l).map(|c| &c.kind), Some(CurveKind::Line { .. })))
})
}
};
if !valid {
+9 -2
View File
@@ -400,7 +400,12 @@ fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
use ConstraintKind::*;
match *k {
PatternPoint { p, q, center, angle, offset } => {
PatternPoint { p, q, center, angle, mut offset, directions } => {
for (l, distance) in directions.into_iter().flatten() {
let (a, b) = s.line(l);
let u = (b - a).normalized().unwrap_or(Vec2::ZERO);
offset += u * distance.x + u.perp() * distance.y;
}
let c = center.map(|c| s.p(c)).unwrap_or(Vec2::ZERO);
let d = s.p(p) - c;
let (sin, cos) = angle.sin_cos();
@@ -775,7 +780,9 @@ fn block_refs(sk: &Sketch, b: &Block) -> Vec<Ref> {
Block::Closure { a, b } => vec![P(*a), P(*b)],
Block::Arc { c, a, b } => vec![P(*c), P(*a), P(*b)],
Block::User(_, k) => match **k {
PatternPoint { p, q, center, .. } => vec![P(p), P(q)].into_iter().chain(center.map(P)).collect(),
PatternPoint { p, q, center, directions, .. } => {
vec![P(p), P(q)].into_iter().chain(center.map(P)).chain(directions.into_iter().flatten().map(|(l, _)| L(l))).collect()
}
PatternRadius { a, b } => vec![C(a), C(b)],
Coincident { p, q }
| HorizontalPoints { p, q }
+11
View File
@@ -1531,12 +1531,17 @@ fn entry_row(
acts: &mut Actions,
) {
app.tree.order.push(e.row_key.clone());
let pattern_sketch =
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
let pattern_key = format!("{}/patterns", e.row_key);
let patterns_open = is_open(app, &pattern_key, true);
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
let r = draw_row(
ui,
ui.id().with(("item", &e.row_key)),
&Row {
depth,
fold: pattern_sketch.map(|_| patterns_open),
eye: Some(e.visible),
icon: e.icon,
label: &e.label,
@@ -1548,6 +1553,9 @@ fn entry_row(
..Default::default()
},
);
if r.fold {
toggle(app, &pattern_key, true);
}
if folder == "bodies"
&& app.tree.reveal.as_deref() == Some(e.key.as_str())
&& let Some(rect) = r.rect
@@ -1587,6 +1595,9 @@ fn entry_row(
}
acts.menu = Some((p, t));
}
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)));
}
let Some(resp) = &r.resp else { return };
// Drag: the selected items of this folder when this one is selected, else just this one.
if resp.drag_started() && !r.on_control {
+1 -1
View File
@@ -494,7 +494,7 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(11.5));
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
crate::params_dialog::complete(ui, &r, value);
if select_all {
+103 -22
View File
@@ -819,7 +819,8 @@ impl Dialog {
/// Dialogs holding a table get the wide limit.
pub fn is_wide(&self) -> bool {
matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide())
matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|| matches!(&self.kind, Kind::Motion(k) if k.wide())
}
pub fn wants_picks(&self) -> bool {
@@ -1609,6 +1610,10 @@ fn selection_label(s: &Session, item: &Sel) -> String {
/// 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, s: &Session) {
input_rows_range(d, ui, s, 0..d.inputs.len());
}
fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::ops::Range<usize>) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
return;
@@ -1616,7 +1621,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
let t = Tokens::get();
let mut clear: Option<usize> = None;
let mut activate: Option<usize> = None;
for (i, inp) in d.inputs.iter().enumerate() {
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
row_label(ui, inp.label);
ui.horizontal(|ui| {
let active = i == d.active;
@@ -1685,6 +1690,87 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
}
}
/// Each live reference sits with the independent values it drives.
fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session) -> bool {
use crate::sketch_modify_ui::Mod;
use solidcraft_engine::sketch::PatternAlignment;
let mut enter = false;
ui.vertical(|ui| {
ui.label(tr("dialog.selection_guidance"));
egui::Grid::new("pattern_source").spacing(vec2(10.0, 8.0)).show(ui, |ui| input_rows_range(d, ui, session, 0..1));
ui.separator();
ui.columns(2, |columns| {
for (index, ui) in columns.iter_mut().enumerate() {
ui.strong(tr(if index == 0 { "Direction 1" } else { "Direction 2" }));
egui::Grid::new(("pattern_direction", index)).spacing(vec2(8.0, 8.0)).show(ui, |ui| {
input_rows_range(d, ui, session, index + 1..index + 2);
let Kind::SketchModify(Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 }) =
&mut d.kind
else {
return;
};
let (count, spacing, alignment, reversed, count_label, spacing_label, alignment_label) = if index == 0 {
(count, spacing, alignment, reversed, "Quantity", "Spacing", "Alignment")
} else {
(count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 2")
};
row_label(ui, count_label);
enter |= field(ui, count);
ui.end_row();
row_label(ui, spacing_label);
enter |= field(ui, spacing);
ui.end_row();
row_label(ui, alignment_label);
ui.horizontal(|ui| {
pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);
let mut centered = *alignment == PatternAlignment::Center;
pattern_toggle(ui, alignment_label, "Center", "pattern_center", &mut centered);
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
});
ui.end_row();
});
}
});
ui.small(tr("Counts include the original. Center balances copies; Flip chooses the extra side for even totals."));
});
ui.end_row();
enter
}
pub(crate) fn pattern_toggle(ui: &mut egui::Ui, row: &str, label: &str, icon: &str, selected: &mut bool) {
let response = ui.add(egui::Button::new("").selected(*selected).min_size(vec2(28.0, 24.0)));
let t = Tokens::get();
crate::icons::paint(ui.painter(), response.rect.shrink(3.0), icon, t.icon, t.icon_fill, t.accent);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, *selected, tr(label)));
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
if response.on_hover_text(tr(label)).clicked() {
*selected = !*selected;
}
}
fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui, full_width: bool) {
if matches!(d.kind, Kind::SketchModify(_))
&& d.previews()
&& let Err(error) = apply_commands(app, d)
{
d.refuse(app, error);
}
if let Some(error) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
if !full_width {
row_label(ui, "");
}
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(error.as_str()).color(Tokens::get().error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
}
});
ui.end_row();
}
}
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
/// Width of value fields and choice boxes in a dialog.
@@ -1998,6 +2084,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut cancel = false;
let mut enter = false;
let wide = d.is_wide();
let pattern_columns = matches!(d.kind, Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }));
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let frame = crate::frame::dock_frame(ctx, side);
@@ -2027,7 +2114,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
let min_w = if pattern_columns {
540.0
} else if wide {
480.0
} else {
DIALOG_MIN_W
} - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
// Rows that end at the right edge line up with last frame's content width.
@@ -2081,7 +2174,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
}
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
egui::Grid::new("sc_dialog_grid").num_columns(if pattern_columns { 1 } else { 2 }).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if pattern_columns {
enter |= rectangular_pattern_rows(&mut d, ui, &app.session);
error_rows(app, ctx, &mut d, ui, true);
return;
}
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
return;
}
@@ -2798,24 +2896,7 @@ 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| {
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
}
});
ui.end_row();
}
error_rows(app, ctx, &mut d, ui, false);
});
});
ui.add_space(6.0);
+1
View File
@@ -148,6 +148,7 @@ pub mod sketch_modify_ui;
#[cfg(test)]
mod sketch_modify_ui_tests;
pub mod sketch_palette;
mod sketch_pattern_tree;
pub(crate) mod sketch_text;
mod sketch_text_handles;
mod sketch_text_move;
@@ -64,6 +64,8 @@ fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
#[test]
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
let mut h = fixture();
// Keep the picked geometry clear of the two-column rectangular-pattern form.
step(&mut h, json!({"call":"ui.camera","params":{"target":[-60,0,0],"distance":120}}));
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 {
@@ -78,7 +80,7 @@ fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
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 {
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();
}
+94 -27
View File
@@ -28,9 +28,10 @@ use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::{Vec2, Vec3};
use solidcraft_engine::sketch::PatternAlignment;
use crate::SolveApp;
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, pattern_toggle, row_label};
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
use crate::viewport::{Hit, Proj};
@@ -76,11 +77,20 @@ pub enum Mod {
/// carries the copy flag, so the Create Copy row needs no engine change.
Move { x: String, y: String, angle: String, copy: bool },
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
/// apart, then the same along the perpendicular direction the command derives from it.
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
/// apart, then the same along a second picked direction (perpendicular by default).
PatternRect {
count: String,
spacing: String,
count2: String,
spacing2: String,
alignment: PatternAlignment,
alignment2: PatternAlignment,
reversed: bool,
reversed2: bool,
},
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
/// angle they span.
PatternCirc { count: String, angle: String },
PatternCirc { count: String, angle: String, alignment: PatternAlignment, reversed: bool },
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
@@ -132,7 +142,11 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
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 | SKETCH_POINTS, true), SelInput::new("Direction", AXES, false)],
Mod::PatternRect { .. } => vec![
SelInput::new("Entities", CURVES | SKETCH_POINTS, true),
SelInput::new("Direction", AXES, false),
SelInput::new("Direction 2", AXES, false),
],
// The centre is required: `sketch.pattern.circular` has no default for it.
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
@@ -157,8 +171,19 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, chain: true, flip: false },
"sketch.mirror" => Mod::Mirror,
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
"sketch.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() },
"sketch.pattern.rectangular" => Mod::PatternRect {
count: "3".into(),
spacing: "20 mm".into(),
count2: "1".into(),
spacing2: "20 mm".into(),
alignment: PatternAlignment::Right,
alignment2: PatternAlignment::Right,
reversed: false,
reversed2: false,
},
"sketch.pattern.circular" => {
Mod::PatternCirc { count: "6".into(), angle: "360 deg".into(), alignment: PatternAlignment::Right, reversed: false }
}
"sketch.fillet" => corner(false, 0),
"sketch.chamfer.equal_distance" => corner(true, 0),
"sketch.chamfer.two_distance" => corner(true, 1),
@@ -188,12 +213,21 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
let kind = match &pattern.kind {
SketchPatternKind::Circular { count, angle, .. } => Mod::PatternCirc { count: count.to_string(), angle: format!("{angle} deg") },
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => Mod::PatternRect {
SketchPatternKind::Circular { count, angle, alignment, reversed, .. } => Mod::PatternCirc {
count: count.to_string(),
angle: format!("{angle} deg"),
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
},
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2, .. } => Mod::PatternRect {
count: count.to_string(),
spacing: format!("{spacing} mm"),
count2: count2.to_string(),
spacing2: format!("{spacing2} mm"),
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
alignment2: if *alignment2 == PatternAlignment::Left { PatternAlignment::Right } else { *alignment2 },
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
reversed2: *reversed2 ^ (*alignment2 == PatternAlignment::Left),
},
};
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
@@ -210,8 +244,16 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
input.items = vec![Sel::SketchPoint { id: center.clone() }];
}
}
SketchPatternKind::Rectangular { dir, .. } => {
SketchPatternKind::Rectangular { dir, dir2, direction, direction2, .. } => {
dialog.extra.insert("dir".into(), json!(dir));
dialog.extra.insert("dir2".into(), json!(dir2));
for (index, id) in [(1, direction), (2, direction2)] {
if let Some(id) = id
&& let Some(input) = dialog.inputs.get_mut(index)
{
input.items = vec![Sel::SketchCurve { id: id.clone() }];
}
}
}
}
dialog.extra.insert("pattern".into(), json!(id));
@@ -287,19 +329,33 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
check(ui, copy);
ui.end_row();
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
row_label(ui, label);
enter |= field(ui, value);
ui.end_row();
}
}
Mod::PatternCirc { count, angle } => {
// Its two direction columns include both the reference picks and values.
Mod::PatternRect { .. } => {}
Mod::PatternCirc { count, angle, alignment, reversed } => {
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
row_label(ui, label);
enter |= field(ui, value);
ui.end_row();
}
row_label(ui, "Alignment");
ui.horizontal(|ui| {
pattern_toggle(ui, "Alignment", "Flip direction", "pattern_flip", reversed);
let mut centered = *alignment == PatternAlignment::Center;
pattern_toggle(ui, "Alignment", "Center", "pattern_center", &mut centered);
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
});
ui.end_row();
row_label(ui, "");
ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::tr_label(
"Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
))
.small(),
)
.wrap(),
);
ui.end_row();
}
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
if *chamfer {
@@ -349,13 +405,13 @@ fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option<Vec2> {
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
let Kind::SketchModify(k) = &d.kind else { return None };
match k {
Mod::PatternRect { .. } => {
Mod::PatternRect { alignment, reversed, .. } => {
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
let dir = match d.inputs.get(1).and_then(|i| i.items.first()) {
Some(sel) => sketch_dir(app, sel)?,
None => Vec2::X,
};
Some(plane.dir_to_world(dir))
Some(plane.dir_to_world(if (*alignment == PatternAlignment::Left) ^ *reversed { -dir } else { dir }))
}
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
// handle only for Revolve, through machinery tied to its own extents, so the circular
@@ -617,7 +673,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.move", p)
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
@@ -635,16 +691,24 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
if n1.saturating_mul(n2) > MAX_COPIES as u64 {
return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances"));
}
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2});
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2, "alignment": alignment, "alignment2": alignment2, "reversed": reversed, "reversed2": reversed2});
// A picked direction that has no component in the sketch plane is reported rather
// than quietly replaced by the default.
if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) {
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
p["dir"] = json!([d.x, d.y]);
for (index, vector_key, line_key) in [(1, "dir", "direction"), (2, "dir2", "direction2")] {
if let Some(sel) = inputs.get(index).and_then(|i| i.items.first()) {
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
p[vector_key] = json!([d.x, d.y]);
p[line_key] = match sel {
Sel::SketchCurve { id } => json!(id),
_ => Value::Null,
};
} else {
p[line_key] = Value::Null;
}
}
("sketch.pattern.rectangular", p)
}
Mod::PatternCirc { count, angle } => {
Mod::PatternCirc { count, angle, alignment, reversed } => {
let entities = entity_ids(&app.session, inputs, 0);
if entities.is_empty() {
return Err("Select the sketch curves or points to pattern first".into());
@@ -663,7 +727,10 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
return Err("Quantity must be at least 2".into());
}
let a = total_angle(app, "Total angle", angle)?;
("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a}))
(
"sketch.pattern.circular",
json!({"entities": entities, "center": center, "count": n, "angle": a, "alignment": alignment, "reversed": reversed}),
)
}
Mod::Corner { chamfer: false, size, .. } => {
let corners = corner_ids(app, inputs, 0);
+123 -3
View File
@@ -456,7 +456,7 @@ fn open(cmd: &str) -> (crate::scenario::Harness, String) {
fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) {
let d = h.app.dialog.as_mut().expect("the window opened");
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind {
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2, .. }) = &mut d.kind {
*c1 = count.into();
*s1 = spacing.into();
*c2 = count2.into();
@@ -472,12 +472,58 @@ fn rectangular_pattern_window_opens_instead_of_a_click_tool() {
assert!(h.app.tool.is_none(), "the click tool still takes the command");
let d = h.app.dialog.as_ref().expect("the window opened");
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind);
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction"]);
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction", "Direction 2"]);
// Nothing is committed by opening the window.
let sid = h.app.session.active_sketch.unwrap();
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1);
}
#[test]
fn rectangular_columns_flip_and_center_independently_and_restore_even_counts_when_editing() {
use solidcraft_engine::sketch::PatternAlignment;
let (mut h, id) = open("sketch.pattern.rectangular");
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
rect(&mut h, "4", "20", "2", "10");
h.settle();
let first = crate::scenario::handle_at("dialog:Spacing").unwrap();
let second = crate::scenario::handle_at("dialog:Spacing 2").unwrap();
assert!(second.x - first.x > 180.0, "the directions should occupy separate columns: {first:?}, {second:?}");
assert!((second.y - first.y).abs() < 2.0, "the independent spacing fields should share a row");
for label in ["Alignment", "Alignment 2"] {
let left = crate::scenario::handle_at(&format!("dialog:{label}:Flip direction")).unwrap();
let right = crate::scenario::handle_at(&format!("dialog:{label}:Center")).unwrap();
assert!((left.y - right.y).abs() < 2.0, "alignment choices should fit on a single line: {left:?}, {right:?}");
}
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Flip direction"}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].1["alignment"], "center");
assert_eq!(commands[0].1["alignment2"], "right");
assert_eq!(commands[0].1["reversed2"], true);
assert_eq!(commands[0].1["count"], 4);
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.settle();
assert!(h.app.dialog.is_none());
let sid = h.app.session.active_sketch.unwrap();
let pattern = h.app.session.world_state().sketch(sid).unwrap().sketch.patterns[0].id.clone();
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &pattern));
assert!(matches!(
h.app.dialog.as_ref().unwrap().kind,
Kind::SketchModify(Mod::PatternRect { alignment: PatternAlignment::Center, alignment2: PatternAlignment::Right, reversed2: true, .. })
));
rect(&mut h, "4", "20", "3", "10");
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_ok());
h.settle();
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Center"}})).unwrap();
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands[0].1["alignment"], "center");
assert_eq!(commands[0].1["reversed"], true);
assert_eq!(commands[0].1["alignment2"], "center");
assert_eq!(commands[0].1["reversed2"], true);
}
#[test]
fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() {
let (mut h, id) = open("sketch.pattern.rectangular");
@@ -506,6 +552,45 @@ fn a_picked_axis_becomes_an_in_plane_direction() {
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane");
}
#[test]
fn two_picked_lines_are_sent_as_live_references_and_restored_in_the_editor() {
let (mut h, id) = open("sketch.pattern.rectangular");
h.app.run("sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]})).unwrap();
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::SketchCurve { id }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "dir1".into() }];
d.inputs[2].items = vec![Sel::SketchCurve { id: "dir2".into() }];
rect(&mut h, "3", "20", "2", "10");
let d = h.app.dialog.clone().unwrap();
let cmds = apply_commands(&h.app, &d).unwrap();
assert_eq!(cmds[0].1["direction"], "dir1");
assert_eq!(cmds[0].1["direction2"], "dir2");
let out = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, out["pattern"].as_str().unwrap()));
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "dir1".into() }]);
assert_eq!(d.inputs[2].items, vec![Sel::SketchCurve { id: "dir2".into() }]);
let reopened = apply_commands(&h.app, d).unwrap();
assert_eq!(reopened[0].1["direction2"], "dir2");
}
#[test]
fn sketch_pattern_tools_are_promoted_together_and_open_from_the_toolbar() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let panels = crate::toolbar_custom::panel_lists("SKETCH");
let (_, cmds, promoted) = panels.iter().find(|(panel, ..)| *panel == "PATTERN").unwrap();
let ids: Vec<_> = cmds.iter().take(*promoted).map(|c| c.id).collect();
assert_eq!(ids, ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"]);
for id in ids {
h.settle();
h.step(&json!({"click":{"handle":format!("toolbar:{id}")}})).unwrap();
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::SketchModify(_))), "{id}");
h.step(&json!({"key":"Escape"})).unwrap();
}
}
#[test]
fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() {
let (mut h, id) = open("sketch.pattern.rectangular");
@@ -590,7 +675,7 @@ fn open_circ() -> (crate::scenario::Harness, String) {
fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) {
let d = h.app.dialog.as_mut().expect("the window opened");
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind {
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a, .. }) = &mut d.kind {
*c = count.into();
*a = angle.into();
} else {
@@ -627,6 +712,41 @@ fn the_typed_count_and_total_angle_reach_the_command() {
assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0));
}
#[test]
fn circular_flip_and_center_buttons_apply_and_reopen_the_saved_placement() {
use solidcraft_engine::sketch::{PatternAlignment, SketchPatternKind};
let (mut h, _) = open_circ();
circ(&mut h, "6", "90 deg");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
let d = h.app.dialog.clone().unwrap();
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
assert_eq!(params["count"], json!(6));
assert_eq!(params["angle"], json!(90.));
assert_eq!(params["alignment"], json!("center"));
assert_eq!(params["reversed"], json!(true));
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let id = {
let state = h.app.session.world_state();
let pattern = &state.sketch(sid).unwrap().sketch.patterns[0];
assert!(matches!(pattern.kind, SketchPatternKind::Circular { alignment: PatternAlignment::Center, reversed: true, .. }));
pattern.id.clone()
};
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &id));
assert!(matches!(
h.app.dialog.as_ref().unwrap().kind,
Kind::SketchModify(Mod::PatternCirc { alignment: PatternAlignment::Center, reversed: true, .. })
));
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
let d = h.app.dialog.clone().unwrap();
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
assert_eq!(params["alignment"], json!("right"));
assert_eq!(params["reversed"], json!(true));
}
/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular`
/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated
/// here or it disappears in silence.
+132
View File
@@ -0,0 +1,132 @@
//! Sketch patterns are children of their sketch, rather than separate timeline features.
use serde_json::json;
use solidcraft_engine::{Sel, sketch::SketchPatternKind};
use crate::{
SolveApp,
browser::{Row, draw_row},
};
pub(crate) fn has_patterns(app: &SolveApp, sketch: u64) -> bool {
app.session.doc.sketch(sketch).is_ok_and(|sk| !sk.patterns.is_empty())
}
/// Shared by the parts browser and the vertical feature history. The stored document is the
/// source, so rolled-back sketches retain their children without showing preview-only copies.
pub(crate) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, sketch: u64, depth: usize, scope: &str, occurrence: Option<(u64, &[u64])>) {
let patterns: Vec<_> = app.session.doc.sketch(sketch).map(|sk| sk.patterns.clone()).unwrap_or_default();
for pattern in patterns {
let (name, icon) = match pattern.kind {
SketchPatternKind::Rectangular { .. } => ("Rectangular Pattern", "sketch_rect_pattern"),
SketchPatternKind::Circular { .. } => ("Circular Pattern", "sketch_circ_pattern"),
};
let suffix = pattern.id.strip_prefix("pat").unwrap_or(&pattern.id);
let label = format!("{} {suffix}", crate::i18n::tr_label(name));
let active = app.session.active_sketch == Some(sketch);
let curves: Vec<_> = app
.session
.doc
.sketch(sketch)
.map(|sk| {
pattern
.entities
.iter()
.chain(pattern.instances.iter().flat_map(|i| &i.curves))
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
.collect()
})
.unwrap_or_default();
let selected = active && !curves.is_empty() && curves.iter().all(|curve| app.session.selection.contains(curve));
let response = draw_row(
ui,
ui.id().with(("sketch_pattern", sketch, &pattern.id)),
&Row { depth, icon, label: &label, name: true, selected, ..Default::default() },
);
if let Some(rect) = response.rect {
crate::scenario::publish_handle(&format!("{scope}:sketch-pattern:{sketch}:{}", pattern.id), rect.center());
}
if response.double {
if let Some((component, path)) = occurrence {
let _ = app.run("component.activate", json!({"component":component,"occurrence":path.last(),"path":path}));
}
app.edit_sketch(sketch);
if app.session.active_sketch == Some(sketch) {
crate::sketch_modify_ui::edit_pattern(app, &pattern.id);
}
} else if response.clicked {
let items = if active { curves } else { vec![Sel::Feature { id: sketch }] };
let _ = app.run("select.set", json!({"items":items}));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{dialogs::Kind, scenario::Harness, sketch_modify_ui::Mod};
fn harness(left_history: bool) -> (Harness, u64, String, String) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_browser = true;
h.app.preferences.timeline_left = left_history;
h.app.run("sketch.create", json!({"plane":"XY","name":"Patterned Sketch"})).unwrap();
let sketch = h.app.session.active_sketch.unwrap();
h.app.run("sketch.line", json!({"points":[[10,5],[15,5]],"ids":["source"]})).unwrap();
let rectangular = h
.app
.run("sketch.pattern.rectangular", json!({"entities":["source"],"count":4,"spacing":20,"alignment":"center","reversed":true}))
.unwrap();
let circular = h.app.run("sketch.pattern.circular", json!({"entities":["source"],"center":"origin","count":3})).unwrap();
h.app.finish_sketch();
h.frames(6);
(h, sketch, rectangular["pattern"].as_str().unwrap().into(), circular["pattern"].as_str().unwrap().into())
}
#[test]
fn browser_patterns_fold_with_the_sketch_and_double_click_opens_the_saved_editor() {
let (mut h, sketch, rectangular, circular) = harness(false);
let handle = format!("browser:sketch-pattern:{sketch}:{rectangular}");
let child = crate::scenario::handle_at(&handle).unwrap();
let parent = crate::scenario::handle_at("row:Patterned Sketch").unwrap();
assert!(child.y > parent.y, "patterns belong below their sketch");
assert!(crate::scenario::handle_at(&format!("browser:sketch-pattern:{sketch}:{circular}")).is_some());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
// Let the fold clicks leave egui's triple-click interval before double-clicking a child.
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert_eq!(h.app.session.active_sketch, Some(sketch), "selection={:?}, status={:?}", h.app.session.selection, h.app.status);
let dialog = h.app.dialog.as_ref().unwrap();
assert_eq!(dialog.extra["pattern"], rectangular);
assert!(matches!(&dialog.kind, Kind::SketchModify(Mod::PatternRect { count, reversed: true, .. }) if count == "4"));
}
#[test]
fn left_history_lists_both_pattern_kinds_and_tracks_deletion_and_undo() {
let (mut h, sketch, rectangular, circular) = harness(true);
let handle = format!("timeline:sketch-pattern:{sketch}:{circular}");
assert!(crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).is_some());
assert!(crate::scenario::handle_at(&handle).is_some());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchModify(Mod::PatternCirc { .. })));
assert_eq!(h.app.dialog.as_ref().unwrap().extra["pattern"], circular);
h.step(&json!({"key":"Escape"})).unwrap();
h.app.run("sketch.delete", json!({"entities":["source"]})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.app.run("edit.undo", json!({})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_some());
}
}
+3 -1
View File
@@ -1050,7 +1050,9 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
use solidcraft_engine::sketch::ConstraintKind::*;
match *k {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
PatternPoint { p, q, center, .. } => (vec![], vec![p, q].into_iter().chain(center).collect()),
PatternPoint { p, q, center, directions, .. } => {
(directions.into_iter().flatten().map(|(l, _)| l).collect(), vec![p, q].into_iter().chain(center).collect())
}
PatternRadius { a, b } => (vec![a, b], vec![]),
Fix { p } => (vec![], vec![p]),
Midpoint { p, l } => (vec![l], vec![p]),
+20 -4
View File
@@ -685,7 +685,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
}
slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group });
let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0);
let has_patterns = matches!(kind, FeatureKind::Sketch { .. }) && crate::sketch_pattern_tree::has_patterns(app, *id);
let pattern_key = format!("timeline/sketch/{id}/patterns");
let patterns_open = crate::browser::is_open(app, &pattern_key, true);
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
let fold_rect = Rect::from_min_size(rect.left_top() + vec2(indent, 0.0), vec2(16.0, rect.height()));
let on_fold = has_patterns && response.interact_pointer_pos().is_some_and(|p| fold_rect.contains(p));
crate::scenario::publish_handle(&format!("timeline:{name}"), rect.center());
let result = app.session.model.result(*id);
let error = result.and_then(|result| result.error.clone());
@@ -705,7 +710,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
if background != Color32::TRANSPARENT {
ui.painter().rect_filled(rect, 3.0, background);
}
let icon_rect = Rect::from_center_size(pos2(rect.left() + 13.0 + indent, rect.center().y), vec2(18.0, 18.0));
if has_patterns {
crate::icons::glyphs::disclosure(ui.painter(), fold_rect.shrink(3.0), patterns_open, t.text_dim);
crate::scenario::publish_handle(&format!("timeline-sketch-fold:{name}"), fold_rect.center());
}
let icon_rect =
Rect::from_center_size(pos2(rect.left() + 13.0 + indent + if has_patterns { 16.0 } else { 0.0 }, rect.center().y), vec2(18.0, 18.0));
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(ui.painter(), icon_rect, icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
if *suppressed {
@@ -737,7 +747,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
if response.hovered() {
hovered = Some(*id);
}
if response.drag_started() {
if response.drag_started() && !on_fold {
if !selected {
select_only(app, TimelineItem::Feature(*id));
}
@@ -753,10 +763,12 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
}
tip.push_str("\nDouble-click to edit · drag to reorder · right-click for more");
let response = response.on_hover_text(tip);
if response.clicked() {
if response.clicked() && on_fold {
crate::browser::toggle(app, &pattern_key, true);
} else if response.clicked() {
select_item(app, ui, TimelineItem::Feature(*id), &order);
}
if response.double_clicked() {
if response.double_clicked() && !on_fold {
edit = Some(*id);
}
let sketch = match kind {
@@ -800,6 +812,10 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
});
if has_patterns && patterns_open {
let depth = group_id.map_or(1, |group| group_depth(&groups, group) + 2);
crate::sketch_pattern_tree::rows(app, ui, *id, depth, "timeline", None);
}
previous_visible_group = group_id;
}
let mut trailing: Vec<_> = groups.iter().filter(|group| !shown_groups.contains(&group.id)).collect();
+2 -1
View File
@@ -52,7 +52,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
}
match tab {
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SKETCH" => &["CREATE", "MODIFY", "PATTERN", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
@@ -69,6 +69,7 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
("SKETCH", "CONSTRAINTS") => 6,
("SKETCH", "PATTERN") => 3,
(_, "FINISH SKETCH") => 1,
_ => 2,
}
+1
View File
@@ -119,6 +119,7 @@ pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(
tab,
),
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
("SKETCH", "PATTERN") => (&["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"], 3, tab),
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab),
@@ -0,0 +1,48 @@
[
{"note":"Each column has independent spacing, quantity and alignment. Center keeps the original in the middle; Left follows the negative second guide."},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
{"call":"ui.view","params":{"view":"top"}},
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
{"click":{"sketch":[7,5]}},
{"click":{"handle":"dialog:Direction"}},
{"click":{"sketch":[10,-10]}},
{"click":{"handle":"dialog:Direction 2"}},
{"click":{"sketch":[6,-22]}},
{"click":{"handle":"dialog:Quantity"}},
{"key":"A","cmd":true},
{"text":"3"},
{"click":{"handle":"dialog:Spacing"}},
{"key":"A","cmd":true},
{"text":"20"},
{"click":{"handle":"dialog:Quantity 2"}},
{"key":"A","cmd":true},
{"text":"2"},
{"click":{"handle":"dialog:Spacing 2"}},
{"key":"A","cmd":true},
{"text":"10"},
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
{"click":{"handle":"dialog:Alignment:Center"}},
{"click":{"handle":"dialog:Alignment 2:Flip direction"}},
{"shot":"sketch_pattern_alignment_preview"},
{"click":{"handle":"dialog:OK"}},
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":8}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-21}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":-3}}},
{"call":"ui.view","params":{"view":"fit"}},
{"shot":"sketch_pattern_alignment_before"},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-19}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":0}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-21}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":2}}},
{"expect":{"errors":0,"sketch_curves":{"own":8}}},
{"shot":"sketch_pattern_alignment_after"}
]
@@ -0,0 +1,33 @@
[
{"note":"Six total holes span 90 degrees around the original; Flip chooses the extra side without moving the seed."},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[40,0],"radius":2,"id":"seed"}}},
{"call":"engine.execute","params":{"command":"sketch.point","params":{"point":[0,0],"id":"axis_center"}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["axis_center"]}}},
{"call":"ui.view","params":{"view":"top"}},
{"call":"ui.camera","params":{"target":[25,0,0],"distance":120}},
{"start":"sketch.pattern.circular"},
{"click":{"sketch":[42,0]}},
{"click":{"handle":"dialog:Center"}},
{"click":{"sketch":[0,0]}},
{"click":{"handle":"dialog:Quantity"}},
{"key":"A","cmd":true},
{"text":"6"},
{"click":{"handle":"dialog:Total angle"}},
{"key":"A","cmd":true},
{"text":"90 deg"},
{"click":{"handle":"dialog:Alignment:Center"}},
{"shot":"sketch_pattern_circular_center_before_flip"},
{"click":{"handle":"dialog:Alignment:Flip direction"}},
{"shot":"sketch_pattern_circular_center_after_flip"},
{"click":{"handle":"dialog:OK"}},
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":6}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",0],"value":23.511410091698927}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",1],"value":-32.3606797749979}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",0,"center_at",0],"value":40}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",0,"center_at",1],"value":0}}},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[40,0],"to":[45,0]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",0],"value":26.45033635316129}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",1],"value":-36.405764746872634}}},
{"shot":"sketch_pattern_circular_center_source_edit"}
]
@@ -0,0 +1,49 @@
[
{"note":"Even centered totals keep the original fixed; Flip chooses the extra side independently for each direction."},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
{"call":"ui.view","params":{"view":"top"}},
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
{"click":{"sketch":[7,5]}},
{"click":{"handle":"dialog:Direction"}},
{"click":{"sketch":[10,-10]}},
{"click":{"handle":"dialog:Direction 2"}},
{"click":{"sketch":[6,-22]}},
{"click":{"handle":"dialog:Quantity"}},
{"key":"A","cmd":true},
{"text":"4"},
{"click":{"handle":"dialog:Spacing"}},
{"key":"A","cmd":true},
{"text":"20"},
{"click":{"handle":"dialog:Quantity 2"}},
{"key":"A","cmd":true},
{"text":"2"},
{"click":{"handle":"dialog:Spacing 2"}},
{"key":"A","cmd":true},
{"text":"10"},
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
{"click":{"handle":"dialog:Alignment:Center"}},
{"click":{"handle":"dialog:Alignment 2:Center"}},
{"click":{"handle":"dialog:Alignment:Flip direction"}},
{"shot":"sketch_pattern_even_center_preview"},
{"click":{"handle":"dialog:OK"}},
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":10}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-29}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":13}}},
{"call":"ui.view","params":{"view":"fit"}},
{"shot":"sketch_pattern_even_center_before"},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-27}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":16}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-25}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":14}}},
{"expect":{"errors":0,"sketch_curves":{"own":10}}},
{"shot":"sketch_pattern_even_center_after"}
]
@@ -0,0 +1,38 @@
[
{"note":"The PATTERN toolbar opens a linear pattern with two independent picked direction lines. Copies follow the original and both lines."},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
{"call":"ui.view","params":{"view":"top"}},
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
{"click":{"sketch":[7,5]}},
{"click":{"handle":"dialog:Direction"}},
{"click":{"sketch":[10,-10]}},
{"click":{"handle":"dialog:Direction 2"}},
{"click":{"sketch":[6,-22]}},
{"click":{"handle":"dialog:Quantity"}}, {"key":"A","cmd":true}, {"text":"3"},
{"click":{"handle":"dialog:Spacing"}}, {"key":"A","cmd":true}, {"text":"20"},
{"click":{"handle":"dialog:Quantity 2"}}, {"key":"A","cmd":true}, {"text":"2"},
{"click":{"handle":"dialog:Spacing 2"}}, {"key":"A","cmd":true}, {"text":"10"},
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
{"shot":"sketch_pattern_two_directions_preview"},
{"click":{"handle":"dialog:OK"}},
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":8}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":51}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":13}}},
{"call":"ui.view","params":{"view":"fit"}},
{"shot":"sketch_pattern_two_directions_before"},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":53}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":16}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":55}}},
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":14}}},
{"expect":{"errors":0,"sketch_curves":{"own":8}}},
{"shot":"sketch_pattern_two_directions_after"}
]
+3
View File
@@ -91,6 +91,9 @@ in the file). It opens designs of every older format and upgrades them as they o
from a newer version asks you to update instead of being guessed at. Development (nightly) builds
save a format number no release reads yet.
Sketch patterns retain their live direction-line references and placement settings when saved.
Older patterns keep their fixed vector directions and original placement defaults.
Upgrading keeps what a design meant. Designs keep recorded body transfers, explicit 3D curve coordinate
spaces and certified face bindings, alongside semantic configuration styles, associative
free-form offsets and cylindrical emboss selections. Older ownership maps, geometry and
+72
View File
@@ -0,0 +1,72 @@
# Sketch patterns
While editing a sketch, use the **PATTERN** toolbar group for **Mirror**,
**Rectangular Pattern** or **Circular Pattern**. On a narrow window the group
collapses to a dropdown. Select several curves before starting a tool, or add
them to its **Entities** input. Each tool previews the result before **OK**;
**Escape** cancels it.
- **Mirror:** select the source geometry and a straight sketch **Mirror line**.
Symmetry constraints keep the copies linked to the source and mirror line.
- **Rectangular Pattern:** pick **Direction** and optionally **Direction 2**.
Each accepts a straight sketch line or an origin axis in the sketch plane.
The lines can meet at any angle. Each direction has its own column with a line
picker, quantity, spacing and two icon buttons: **Flip direction** and **Center**.
Without Center, copies extend from the original along the line from start to end;
Flip reverses that direction. Center distributes copies on both sides while the
original remains fixed. Quantities are totals including the original: 3 means
one copy on either side; 4 means two on one side and one on the other. Flip swaps
which side receives the extra copy. Both controls are independent for each direction.
Negative spacing reverses the chosen direction. With no direction selected, the first uses sketch X
and the second is perpendicular to the first. A second quantity of 1 makes
a single row. Picked sketch lines remain live references as they change.
- **Circular Pattern:** pick a **Center** point (or click empty sketch space),
then set **Quantity** and **Total angle**. A full turn distributes instances
evenly without duplicating the first; a partial turn includes both endpoints.
**Center** places copies on both angular sides of the original. **Flip direction**
reverses rotation and chooses the extra side for even totals. For six total holes
over 90 degrees, the spacing is 18 degrees: the original stays at 0 degrees, with
copies at +18, -18, +36, -36 and +54 degrees. Flip moves the last copy to -54 degrees.
Five holes over 90 degrees use 22.5-degree spacing and extend 45 degrees either side.
Without Center, a positive angle proceeds counterclockwise from the original;
Flip reverses it.
Quantities include the original. Edit the original lines or their dimensions
to update their copies. Right-click a rectangular or circular pattern instance
and choose **Edit Sketch Pattern**, or double-click its pattern constraint badge,
to change the quantities, spacing, directions or angle. Rectangular and circular
patterns also appear as children beneath their sketch in the browser and left
feature history. Expand the sketch to find them; double-click a
pattern to open its saved editor, including from a finished sketch. Surviving copies keep
their IDs. Patterns support undo/redo and survive saving and reopening.
The same operations are available through `sketch.mirror`,
`sketch.pattern.rectangular` and `sketch.pattern.circular`. For example:
```json
{
"command": "sketch.pattern.rectangular",
"params": {
"entities": ["l1", "l2", "l3"],
"direction": "guide1",
"direction2": "guide2",
"count": 3,
"spacing": 20,
"count2": 2,
"spacing2": 10,
"alignment": "center",
"alignment2": "center",
"reversed": false,
"reversed2": true
}
}
```
`direction` and `direction2` reference sketch line IDs. `dir` and `dir2`
instead accept fixed `[x,y]` vectors. Distances are millimetres; the circular
command's `angle` parameter is in degrees. The combined quantity is capped at 500.
Deleting a direction line breaks its rectangular pattern association and leaves
the remaining geometry in place; undo restores the association.
Circular patterns accept the same `alignment` and `reversed` parameters, for example
`{"entities":["hole"],"center":[0,0],"count":6,"angle":90,"alignment":"center","reversed":false}`.