diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 8f4fb5de..64ba3d46 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -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); diff --git a/assets/locales/de.json b/assets/locales/de.json index 37247a44..a66781c9 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index 97a63780..a43d5e63 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index be28647c..ef81a808 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -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É", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 1a454e41..5cf189b3 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "傾斜平面", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 29fd3eb0..b091bd81 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "각도 평면", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 630f085f..9557c926 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "ПЛОСКОСТЬ ПОД УГЛОМ", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 8ccc757a..e916335a 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -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", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index ce842bcb..8ed2feab 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -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": "ПЛОЩИНА ПІД КУТОМ", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index b4082881..fc26c846 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "角度平面", diff --git a/crates/doc/src/format/pending/sketch-pattern-directions.rs b/crates/doc/src/format/pending/sketch-pattern-directions.rs new file mode 100644 index 00000000..18314a6b --- /dev/null +++ b/crates/doc/src/format/pending/sketch-pattern-directions.rs @@ -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. diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index c8f58afb..43898ad9 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -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 { 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) { diff --git a/crates/engine/src/cmd/sketch_pattern.rs b/crates/engine/src/cmd/sketch_pattern.rs index 78fe29de..d0b445f5 100644 --- a/crates/engine/src/cmd/sketch_pattern.rs +++ b/crates/engine/src/cmd/sketch_pattern.rs @@ -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 { 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 { 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(¶ms, "alignment", cmd)?; + let reversed = reversed_param(¶ms, "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::>(), + (1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::>(), ) } else { let spacing = num(¶ms, "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 { 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, ¶ms, "direction", cmd)?; + let direction2 = direction_ref(sk, ¶ms, "direction2", cmd)?; + let alignment = alignment_param(¶ms, "alignment", cmd)?; + let alignment2 = alignment_param(¶ms, "alignment2", cmd)?; + let reversed = reversed_param(¶ms, "reversed", cmd)?; + let reversed2 = reversed_param(¶ms, "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 { 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, ¶ms, "direction", cmd)?; + direction_ref(sk, ¶ms, "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 { 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 { } Ok(out) } + +fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result> { + 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 { + 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 { + 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 { + 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) +} diff --git a/crates/engine/src/cmd/sketch_pattern_tests.rs b/crates/engine/src/cmd/sketch_pattern_tests.rs index a00f03f9..0e3a15df 100644 --- a/crates/engine/src/cmd/sketch_pattern_tests.rs +++ b/crates/engine/src/cmd/sketch_pattern_tests.rs @@ -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", ¶ms).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", ¶ms).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", ¶ms).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(); diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 8b08f97f..75e519b1 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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, diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 042d968b..493df7b0 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -189,6 +189,9 @@ pub enum ConstraintKind { center: Option, 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, Vec) { 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 diff --git a/crates/sketch/src/pattern.rs b/crates/sketch/src/pattern.rs index ac9d3e18..79cf990b 100644 --- a/crates/sketch/src/pattern.rs +++ b/crates/sketch/src/pattern.rs @@ -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, + #[serde(default)] + direction: Option, + #[serde(default)] + direction2: Option, + 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 { diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index b7f6b093..360b4c33 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -400,7 +400,12 @@ fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 { fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { 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 { 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 } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index e2e69568..39df45a2 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -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::().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 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 95929af3..28230881 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3f264c7e..33bba37f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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) { // 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 = None; let mut activate: Option = 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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6a5bf6b4..f09b2e48 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 38389e34..88c8cd3a 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -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(); } diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 8de84bed..53f5e973 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -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 { 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)> { "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 { pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { 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 { + 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 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 { let corners = corner_ids(app, inputs, 0); diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index 783ef804..c88a575c 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -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!["Entities", "Direction"]); + assert_eq!(d.inputs.iter().map(|i| i.label).collect::>(), 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. diff --git a/crates/ui-egui/src/sketch_pattern_tree.rs b/crates/ui-egui/src/sketch_pattern_tree.rs new file mode 100644 index 00000000..09c2cbea --- /dev/null +++ b/crates/ui-egui/src/sketch_pattern_tree.rs @@ -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()); + } +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index cc93ae56..34a14c47 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -1050,7 +1050,9 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec (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]), diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index d701ec49..51e1f09a 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -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(); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ffc53d8c..56cf0166 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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, } diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index a3b04c2b..bc3438c3 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -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), diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_alignment.json b/crates/ui-egui/tests/scenarios/sketch_pattern_alignment.json new file mode 100644 index 00000000..2bf99454 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_alignment.json @@ -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"} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json b/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json new file mode 100644 index 00000000..8f024092 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json @@ -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"} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_even_center.json b/crates/ui-egui/tests/scenarios/sketch_pattern_even_center.json new file mode 100644 index 00000000..7f1f38e4 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_even_center.json @@ -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"} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_two_directions.json b/crates/ui-egui/tests/scenarios/sketch_pattern_two_directions.json new file mode 100644 index 00000000..d9131bd7 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_two_directions.json @@ -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"} +] diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 888e04df..f0e5eeb3 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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 diff --git a/docs/sketch-patterns.md b/docs/sketch-patterns.md new file mode 100644 index 00000000..1a0b7a4b --- /dev/null +++ b/docs/sketch-patterns.md @@ -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}`.