Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)
Thanks @Karl-VonRichter.
This commit is contained in:
1 parent
44968dbfe6
commit
371578a06f
36 files changed
+1394
-87
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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É",
|
||||
|
||||
@@ -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": "傾斜平面",
|
||||
|
||||
@@ -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": "각도 평면",
|
||||
|
||||
@@ -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": "ПЛОСКОСТЬ ПОД УГЛОМ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ПЛОЩИНА ПІД КУТОМ",
|
||||
|
||||
@@ -671,6 +671,11 @@
|
||||
"no interference": "无干涉",
|
||||
"no joint that moves yet": "尚无运动接头",
|
||||
"no picture": "无图片",
|
||||
"pattern.alignment_2": "对齐 2",
|
||||
"pattern.alignment_hint": "数量包含原始图形。居中时副本分布在两侧;数量为偶数时,反转可切换多一个副本的一侧。",
|
||||
"pattern.direction_1": "方向 1",
|
||||
"pattern.direction_2": "方向 2",
|
||||
"pattern.flip_direction": "反转方向",
|
||||
"pick a hole to seat it, or OK places it at the origin": "选择一个孔放置,或单击确定放置在原点",
|
||||
"pick one or two vertices, edges or faces": "选择一个或两个顶点、边或面",
|
||||
"render.background": "场景背景",
|
||||
@@ -1118,6 +1123,7 @@
|
||||
"ui.heading_offset_plane": "偏移平面",
|
||||
"ui.heading_ok": "确定",
|
||||
"ui.heading_parameters": "参数",
|
||||
"ui.heading_pattern": "阵列",
|
||||
"ui.heading_pattern_on_path": "路径阵列",
|
||||
"ui.heading_pipe": "管道",
|
||||
"ui.heading_plane_at_angle": "角度平面",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Sketch linear patterns save two independent live direction-line references and each
|
||||
//! direction's alignment and reversal. Even centered totals keep the original fixed and use
|
||||
//! reversal to choose which side receives the extra copy. Older patterns retain their fixed
|
||||
//! vectors and positive-direction placement through defaulted fields, so no rewrite is needed.
|
||||
//! The last release refuses this pending format instead of silently losing the references.
|
||||
@@ -80,20 +80,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_mirror")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_circ_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360). Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction). Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
];
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
@@ -1700,6 +1700,9 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (o, n) in &map {
|
||||
if o != n {
|
||||
cons.push(add_c(sk, ConstraintKind::Symmetric { p: *o, q: *n, l })?);
|
||||
} else if !sk.curves.get(l).is_some_and(|curve| curve.kind.point_ids().contains(o)) {
|
||||
// A shared endpoint must stay on the mirror line as the source changes.
|
||||
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: *o, c: l })?);
|
||||
}
|
||||
}
|
||||
for (o, n) in cs.iter().zip(&made) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
|
||||
|
||||
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
|
||||
use solidcraft_sketch::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -23,9 +23,24 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "use the existing pattern's type"));
|
||||
}
|
||||
let defaults = match &old.kind {
|
||||
SketchPatternKind::Circular { center, count, angle } => json!({"entities":old.entities,"center":center,"count":count,"angle":angle}),
|
||||
SketchPatternKind::Rectangular { dir, count, spacing, count2, spacing2 } => {
|
||||
json!({"entities":old.entities,"dir":dir,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2})
|
||||
SketchPatternKind::Circular { center, count, angle, alignment, reversed } => {
|
||||
json!({"entities":old.entities,"center":center,"count":count,"angle":angle,"alignment":alignment,"reversed":reversed})
|
||||
}
|
||||
SketchPatternKind::Rectangular {
|
||||
dir,
|
||||
dir2,
|
||||
direction,
|
||||
direction2,
|
||||
count,
|
||||
spacing,
|
||||
count2,
|
||||
spacing2,
|
||||
alignment,
|
||||
alignment2,
|
||||
reversed,
|
||||
reversed2,
|
||||
} => {
|
||||
json!({"entities":old.entities,"dir":dir,"dir2":dir2,"direction":direction,"direction2":direction2,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2,"alignment":alignment,"alignment2":alignment2,"reversed":reversed,"reversed2":reversed2})
|
||||
}
|
||||
};
|
||||
if let (Some(params), Some(defaults)) = (params.as_object_mut(), defaults.as_object()) {
|
||||
@@ -78,10 +93,12 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
|
||||
let full = (total.abs() - 360.0).abs() < 1e-9;
|
||||
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
|
||||
let alignment = alignment_param(¶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::<Vec<_>>(),
|
||||
(1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
)
|
||||
} 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<Value> {
|
||||
Some(value) => vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir` must be a finite nonzero vector"))?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
let dir2 = match params.get("dir2") {
|
||||
Some(value) if !value.is_null() => {
|
||||
Some(vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir2` must be a finite nonzero vector"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let direction = direction_ref(sk, ¶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<Value> {
|
||||
if center_id.is_some() && center.is_none() {
|
||||
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
|
||||
}
|
||||
if !circular {
|
||||
// A direction can have belonged to an instance retired above. Resolve it again
|
||||
// before creating relations so an edit never silently substitutes a vector.
|
||||
direction_ref(sk, ¶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<Value> {
|
||||
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
|
||||
let mut cons = Vec::new();
|
||||
for (p, q) in &map {
|
||||
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset })?);
|
||||
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions })?);
|
||||
}
|
||||
for (a, b) in cs.iter().zip(&made) {
|
||||
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
@@ -188,3 +248,53 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(id)) if line_direction(sk, Some(id)).is_some() => Ok(Some(id.clone())),
|
||||
_ => Err(bad(cmd, format!("`{key}` must reference a nondegenerate sketch line"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<PatternAlignment> {
|
||||
p.get(key).map_or(Ok(PatternAlignment::default()), |value| {
|
||||
serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be left, center or right")))
|
||||
})
|
||||
}
|
||||
|
||||
fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
|
||||
p.get(key).map_or(Ok(false), |value| serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be true or false"))))
|
||||
}
|
||||
|
||||
fn line_direction(sk: &Sketch, id: Option<&str>) -> Option<Vec2> {
|
||||
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
|
||||
(b - a).normalized()
|
||||
}
|
||||
|
||||
/// Keep sampled vector directions compatible with older designs, while picked lines are
|
||||
/// solver references. The default second direction follows the first line's perpendicular.
|
||||
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
|
||||
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
|
||||
else {
|
||||
return (fallback, [None; 2]);
|
||||
};
|
||||
let mut offset = Vec2::ZERO;
|
||||
let mut directions = [None; 2];
|
||||
let d1 = *spacing * alignment.multiplier(i, *reversed);
|
||||
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
|
||||
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
|
||||
} else {
|
||||
offset += *dir * d1;
|
||||
if dir2.is_none() && direction2.is_none() {
|
||||
offset += dir.perp() * d2;
|
||||
}
|
||||
}
|
||||
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[1] = Some((l, Vec2::new(d2, 0.0)));
|
||||
} else if let Some(dir2) = dir2 {
|
||||
offset += *dir2 * d2;
|
||||
}
|
||||
(offset, directions)
|
||||
}
|
||||
@@ -4,6 +4,329 @@ fn point(sk: &solidcraft_sketch::Sketch, id: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_centered_six_holes_span_ninety_degrees_and_flip_about_the_original() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":6,"angle":90,"alignment":"center"}));
|
||||
let copies = ids(&out["curves"]);
|
||||
let check = |s: &Session, angles: &[f64], seed: Vec2, center: Vec2| {
|
||||
assert!(point(&sketch(s), "seed.center").dist(seed) < 1e-6);
|
||||
for (id, angle) in copies.iter().zip(angles) {
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
let d = seed - center;
|
||||
let expected = center + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos);
|
||||
assert!(point(&sketch(s), &format!("{id}.center")).dist(expected) < 1e-6, "angle {angle}");
|
||||
}
|
||||
check_relations(s);
|
||||
};
|
||||
check(&s, &[18., -18., 36., -36., 54.], Vec2::new(20., 0.), Vec2::ZERO);
|
||||
let before = s.doc.to_json();
|
||||
let flipped = run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"reversed":true}));
|
||||
assert_eq!(ids(&flipped["curves"]), copies);
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(20., 0.), Vec2::ZERO);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
s.refresh();
|
||||
// Omitting placement settings must retain them after reopening.
|
||||
run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"angle":90}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[20,0],"to":[25,2]}));
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::ZERO);
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]],"fixed":false}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":out["center"],"from":[0,0],"to":[2,3]}));
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::new(2., 3.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_alignment_handles_odd_arcs_one_sided_rotation_and_full_turns() {
|
||||
for (count, angle, alignment, reversed, expected) in [
|
||||
(5, 90., "center", false, vec![22.5_f64, -22.5, 45., -45.]),
|
||||
(3, 90., "right", false, vec![45., 90.]),
|
||||
(3, 90., "right", true, vec![-45., -90.]),
|
||||
(3, 90., "left", false, vec![-45., -90.]),
|
||||
(3, -90., "center", true, vec![45., -45.]),
|
||||
(6, 360., "center", false, vec![60., -60., 120., -120., 180.]),
|
||||
(6, 360., "center", true, vec![-60., 60., -120., 120., -180.]),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.circular",
|
||||
json!({"entities":["seed"],"center":[0,0],"count":count,"angle":angle,"alignment":alignment,"reversed":reversed}),
|
||||
);
|
||||
let mut positions = vec![Vec2::new(20., 0.)];
|
||||
for (id, a) in ids(&out["curves"]).iter().zip(expected) {
|
||||
let pos = point(&sketch(&s), &format!("{id}.center"));
|
||||
let (sin, cos) = a.to_radians().sin_cos();
|
||||
assert!(pos.dist(Vec2::new(20. * cos, 20. * sin)) < 1e-6);
|
||||
assert!(positions.iter().all(|p| pos.dist(*p) > 1.));
|
||||
positions.push(pos);
|
||||
}
|
||||
assert_eq!(positions.len(), count);
|
||||
check_relations(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_invalid_alignment_and_flip_roll_back_and_old_saved_defaults_still_load() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":3,"angle":90}));
|
||||
let before = s.doc.to_json();
|
||||
for extra in [json!({"reversed":"true"}), json!({"reversed":null}), json!({"alignment":"unknown"}), json!({"alignment":null})] {
|
||||
let mut params = json!({"pattern":out["pattern"]});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("sketch.pattern.circular", ¶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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -189,6 +189,9 @@ pub enum ConstraintKind {
|
||||
center: Option<usize>,
|
||||
angle: f64,
|
||||
offset: Vec2,
|
||||
/// Displacements along and perpendicular to live sketch lines (at most two).
|
||||
#[serde(default)]
|
||||
directions: [Option<(usize, Vec2)>; 2],
|
||||
},
|
||||
/// The independent radius of a patterned circle or ellipse follows its seed.
|
||||
PatternRadius {
|
||||
@@ -423,7 +426,9 @@ impl ConstraintKind {
|
||||
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
use ConstraintKind::*;
|
||||
match *self {
|
||||
PatternPoint { p, q, center, .. } => (vec![p, q].into_iter().chain(center).collect(), vec![]),
|
||||
PatternPoint { p, q, center, directions, .. } => {
|
||||
(vec![p, q].into_iter().chain(center).collect(), directions.into_iter().flatten().map(|(l, _)| l).collect())
|
||||
}
|
||||
PatternRadius { a, b } => (vec![], vec![a, b]),
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
|
||||
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]),
|
||||
@@ -458,12 +463,15 @@ impl ConstraintKind {
|
||||
None => ok2 = false,
|
||||
};
|
||||
match self {
|
||||
PatternPoint { p, q, center, .. } => {
|
||||
PatternPoint { p, q, center, directions, .. } => {
|
||||
fp(p);
|
||||
fp(q);
|
||||
if let Some(c) = center {
|
||||
fp(c);
|
||||
}
|
||||
for (l, _) in directions.iter_mut().flatten() {
|
||||
fc(l);
|
||||
}
|
||||
}
|
||||
PatternRadius { a, b } => {
|
||||
fc(a);
|
||||
@@ -848,7 +856,13 @@ impl Sketch {
|
||||
let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) };
|
||||
let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) };
|
||||
match *k {
|
||||
PatternPoint { p, q, angle, offset, .. } => {
|
||||
PatternPoint { p, q, angle, offset, directions, .. } => {
|
||||
for (l, distance) in directions.into_iter().flatten() {
|
||||
need_line(l)?;
|
||||
if !distance.x.is_finite() || !distance.y.is_finite() || distance.x.abs() > MAX_COORD || distance.y.abs() > MAX_COORD {
|
||||
return Err(SketchError::Invalid("invalid pattern direction displacement".into()));
|
||||
}
|
||||
}
|
||||
if p == q
|
||||
|| !angle.is_finite()
|
||||
|| angle.abs() > std::f64::consts::TAU
|
||||
|
||||
@@ -5,11 +5,62 @@ use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::{CurveKind, Sketch, SketchError};
|
||||
|
||||
/// Placement relative to the original, along a pattern direction's oriented line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatternAlignment {
|
||||
Left,
|
||||
Center,
|
||||
#[default]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PatternAlignment {
|
||||
/// Slot zero is always the original. Center alternates sides to retain stable copy ids.
|
||||
pub fn multiplier(self, index: usize, reversed: bool) -> f64 {
|
||||
let value = match self {
|
||||
Self::Left => -(index as f64),
|
||||
Self::Right => index as f64,
|
||||
Self::Center if index.is_multiple_of(2) => -((index / 2) as f64),
|
||||
Self::Center => index.div_ceil(2) as f64,
|
||||
};
|
||||
if reversed { -value } else { value }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SketchPatternKind {
|
||||
Circular { center: String, count: usize, angle: f64 },
|
||||
Rectangular { dir: Vec2, count: usize, spacing: f64, count2: usize, spacing2: f64 },
|
||||
Circular {
|
||||
center: String,
|
||||
count: usize,
|
||||
angle: f64,
|
||||
#[serde(default)]
|
||||
alignment: PatternAlignment,
|
||||
#[serde(default)]
|
||||
reversed: bool,
|
||||
},
|
||||
Rectangular {
|
||||
dir: Vec2,
|
||||
#[serde(default)]
|
||||
dir2: Option<Vec2>,
|
||||
#[serde(default)]
|
||||
direction: Option<String>,
|
||||
#[serde(default)]
|
||||
direction2: Option<String>,
|
||||
count: usize,
|
||||
spacing: f64,
|
||||
count2: usize,
|
||||
spacing2: f64,
|
||||
#[serde(default)]
|
||||
alignment: PatternAlignment,
|
||||
#[serde(default)]
|
||||
alignment2: PatternAlignment,
|
||||
#[serde(default)]
|
||||
reversed: bool,
|
||||
#[serde(default)]
|
||||
reversed2: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -37,6 +88,8 @@ impl SketchPattern {
|
||||
.iter()
|
||||
.any(|instance| instance.curves.iter().any(|id| id == entity) || instance.points.iter().any(|(_, id)| id == entity))
|
||||
|| matches!(&self.kind, SketchPatternKind::Circular { center, .. } if center == entity)
|
||||
|| matches!(&self.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
|
||||
if direction.as_deref() == Some(entity) || direction2.as_deref() == Some(entity))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +106,14 @@ impl Sketch {
|
||||
continue;
|
||||
};
|
||||
match kind {
|
||||
crate::ConstraintKind::PatternPoint { p, q, center, angle, offset } => {
|
||||
crate::ConstraintKind::PatternPoint { p, q, center, angle, mut offset, directions } => {
|
||||
for (l, distance) in directions.into_iter().flatten() {
|
||||
if let Some(crate::Shape::Line { a, b }) = self.shape(l)
|
||||
&& let Some(u) = (b - a).normalized()
|
||||
{
|
||||
offset += u * distance.x + u.perp() * distance.y;
|
||||
}
|
||||
}
|
||||
let Some(seed) = self.point(p) else { continue };
|
||||
let c = center.and_then(|c| self.point(c)).unwrap_or(Vec2::ZERO);
|
||||
let d = seed - c;
|
||||
@@ -95,6 +155,8 @@ impl Sketch {
|
||||
.filter(|pattern| {
|
||||
pattern.entities.iter().any(|id| !curves.contains(id.as_str()) && !points.contains(id.as_str()))
|
||||
|| matches!(&pattern.kind, SketchPatternKind::Circular { center, .. } if !points.contains(center.as_str()))
|
||||
|| matches!(&pattern.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
|
||||
if direction.iter().chain(direction2).any(|id| !curves.contains(id.as_str())))
|
||||
|| pattern.instances.iter().any(|instance| {
|
||||
instance.curves.iter().any(|id| !curves.contains(id.as_str()))
|
||||
|| instance.points.iter().any(|(p, q)| !points.contains(p.as_str()) || !points.contains(q.as_str()))
|
||||
@@ -165,10 +227,10 @@ impl Sketch {
|
||||
let valid_count = |n| (1..=500).contains(&n);
|
||||
let finite = |v: f64| v.is_finite() && v.abs() <= crate::MAX_COORD;
|
||||
let valid = match &pattern.kind {
|
||||
SketchPatternKind::Circular { count, angle, center } => {
|
||||
SketchPatternKind::Circular { count, angle, center, .. } => {
|
||||
valid_count(*count) && angle.is_finite() && angle.abs() > 1e-9 && angle.abs() <= 360.0 && self.resolve_point(center).is_some()
|
||||
}
|
||||
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir } => {
|
||||
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir, dir2, direction, direction2, .. } => {
|
||||
valid_count(*count)
|
||||
&& valid_count(*count2)
|
||||
&& count.checked_mul(*count2).is_some_and(|n| n <= 500)
|
||||
@@ -177,6 +239,10 @@ impl Sketch {
|
||||
&& finite(dir.x)
|
||||
&& finite(dir.y)
|
||||
&& (dir.len() - 1.0).abs() < 1e-6
|
||||
&& dir2.is_none_or(|d| finite(d.x) && finite(d.y) && (d.len() - 1.0).abs() < 1e-6)
|
||||
&& direction.iter().chain(direction2).all(|id| {
|
||||
self.curve_index(id).is_some_and(|l| matches!(self.curves.get(l).map(|c| &c.kind), Some(CurveKind::Line { .. })))
|
||||
})
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
|
||||
@@ -400,7 +400,12 @@ fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
|
||||
use ConstraintKind::*;
|
||||
match *k {
|
||||
PatternPoint { p, q, center, angle, offset } => {
|
||||
PatternPoint { p, q, center, angle, mut offset, directions } => {
|
||||
for (l, distance) in directions.into_iter().flatten() {
|
||||
let (a, b) = s.line(l);
|
||||
let u = (b - a).normalized().unwrap_or(Vec2::ZERO);
|
||||
offset += u * distance.x + u.perp() * distance.y;
|
||||
}
|
||||
let c = center.map(|c| s.p(c)).unwrap_or(Vec2::ZERO);
|
||||
let d = s.p(p) - c;
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
@@ -775,7 +780,9 @@ fn block_refs(sk: &Sketch, b: &Block) -> Vec<Ref> {
|
||||
Block::Closure { a, b } => vec![P(*a), P(*b)],
|
||||
Block::Arc { c, a, b } => vec![P(*c), P(*a), P(*b)],
|
||||
Block::User(_, k) => match **k {
|
||||
PatternPoint { p, q, center, .. } => vec![P(p), P(q)].into_iter().chain(center.map(P)).collect(),
|
||||
PatternPoint { p, q, center, directions, .. } => {
|
||||
vec![P(p), P(q)].into_iter().chain(center.map(P)).chain(directions.into_iter().flatten().map(|(l, _)| L(l))).collect()
|
||||
}
|
||||
PatternRadius { a, b } => vec![C(a), C(b)],
|
||||
Coincident { p, q }
|
||||
| HorizontalPoints { p, q }
|
||||
|
||||
@@ -1531,12 +1531,17 @@ fn entry_row(
|
||||
acts: &mut Actions,
|
||||
) {
|
||||
app.tree.order.push(e.row_key.clone());
|
||||
let pattern_sketch =
|
||||
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
|
||||
let pattern_key = format!("{}/patterns", e.row_key);
|
||||
let patterns_open = is_open(app, &pattern_key, true);
|
||||
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(("item", &e.row_key)),
|
||||
&Row {
|
||||
depth,
|
||||
fold: pattern_sketch.map(|_| patterns_open),
|
||||
eye: Some(e.visible),
|
||||
icon: e.icon,
|
||||
label: &e.label,
|
||||
@@ -1548,6 +1553,9 @@ fn entry_row(
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold {
|
||||
toggle(app, &pattern_key, true);
|
||||
}
|
||||
if folder == "bodies"
|
||||
&& app.tree.reveal.as_deref() == Some(e.key.as_str())
|
||||
&& let Some(rect) = r.rect
|
||||
@@ -1587,6 +1595,9 @@ fn entry_row(
|
||||
}
|
||||
acts.menu = Some((p, t));
|
||||
}
|
||||
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
|
||||
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)));
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started() && !r.on_control {
|
||||
|
||||
@@ -494,7 +494,7 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec
|
||||
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(11.5));
|
||||
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
|
||||
crate::params_dialog::complete(ui, &r, value);
|
||||
if select_all {
|
||||
|
||||
+103
-22
@@ -819,7 +819,8 @@ impl Dialog {
|
||||
|
||||
/// Dialogs holding a table get the wide limit.
|
||||
pub fn is_wide(&self) -> bool {
|
||||
matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide())
|
||||
matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }))
|
||||
|| matches!(&self.kind, Kind::Motion(k) if k.wide())
|
||||
}
|
||||
|
||||
pub fn wants_picks(&self) -> bool {
|
||||
@@ -1609,6 +1610,10 @@ fn selection_label(s: &Session, item: &Sel) -> String {
|
||||
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
|
||||
/// makes it the active input.
|
||||
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
input_rows_range(d, ui, s, 0..d.inputs.len());
|
||||
}
|
||||
|
||||
fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::ops::Range<usize>) {
|
||||
// Edit Text shows its Path input among its own rows.
|
||||
if matches!(d.kind, Kind::Text(_)) {
|
||||
return;
|
||||
@@ -1616,7 +1621,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
let t = Tokens::get();
|
||||
let mut clear: Option<usize> = None;
|
||||
let mut activate: Option<usize> = None;
|
||||
for (i, inp) in d.inputs.iter().enumerate() {
|
||||
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
|
||||
row_label(ui, inp.label);
|
||||
ui.horizontal(|ui| {
|
||||
let active = i == d.active;
|
||||
@@ -1685,6 +1690,87 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each live reference sits with the independent values it drives.
|
||||
fn rectangular_pattern_rows(d: &mut Dialog, ui: &mut egui::Ui, session: &Session) -> bool {
|
||||
use crate::sketch_modify_ui::Mod;
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
let mut enter = false;
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tr("dialog.selection_guidance"));
|
||||
egui::Grid::new("pattern_source").spacing(vec2(10.0, 8.0)).show(ui, |ui| input_rows_range(d, ui, session, 0..1));
|
||||
ui.separator();
|
||||
ui.columns(2, |columns| {
|
||||
for (index, ui) in columns.iter_mut().enumerate() {
|
||||
ui.strong(tr(if index == 0 { "Direction 1" } else { "Direction 2" }));
|
||||
egui::Grid::new(("pattern_direction", index)).spacing(vec2(8.0, 8.0)).show(ui, |ui| {
|
||||
input_rows_range(d, ui, session, index + 1..index + 2);
|
||||
let Kind::SketchModify(Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 }) =
|
||||
&mut d.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (count, spacing, alignment, reversed, count_label, spacing_label, alignment_label) = if index == 0 {
|
||||
(count, spacing, alignment, reversed, "Quantity", "Spacing", "Alignment")
|
||||
} else {
|
||||
(count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 2")
|
||||
};
|
||||
row_label(ui, count_label);
|
||||
enter |= field(ui, count);
|
||||
ui.end_row();
|
||||
row_label(ui, spacing_label);
|
||||
enter |= field(ui, spacing);
|
||||
ui.end_row();
|
||||
row_label(ui, alignment_label);
|
||||
ui.horizontal(|ui| {
|
||||
pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);
|
||||
let mut centered = *alignment == PatternAlignment::Center;
|
||||
pattern_toggle(ui, alignment_label, "Center", "pattern_center", &mut centered);
|
||||
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.small(tr("Counts include the original. Center balances copies; Flip chooses the extra side for even totals."));
|
||||
});
|
||||
ui.end_row();
|
||||
enter
|
||||
}
|
||||
|
||||
pub(crate) fn pattern_toggle(ui: &mut egui::Ui, row: &str, label: &str, icon: &str, selected: &mut bool) {
|
||||
let response = ui.add(egui::Button::new("").selected(*selected).min_size(vec2(28.0, 24.0)));
|
||||
let t = Tokens::get();
|
||||
crate::icons::paint(ui.painter(), response.rect.shrink(3.0), icon, t.icon, t.icon_fill, t.accent);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, *selected, tr(label)));
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
|
||||
if response.on_hover_text(tr(label)).clicked() {
|
||||
*selected = !*selected;
|
||||
}
|
||||
}
|
||||
|
||||
fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui, full_width: bool) {
|
||||
if matches!(d.kind, Kind::SketchModify(_))
|
||||
&& d.previews()
|
||||
&& let Err(error) = apply_commands(app, d)
|
||||
{
|
||||
d.refuse(app, error);
|
||||
}
|
||||
if let Some(error) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
|
||||
if !full_width {
|
||||
row_label(ui, "");
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(error.as_str()).color(Tokens::get().error)).wrap());
|
||||
let response = ui.button("Create Issue…");
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
|
||||
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
|
||||
/// Width of value fields and choice boxes in a dialog.
|
||||
@@ -1998,6 +2084,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut cancel = false;
|
||||
let mut enter = false;
|
||||
let wide = d.is_wide();
|
||||
let pattern_columns = matches!(d.kind, Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. }));
|
||||
let raw_title = title(&d.kind);
|
||||
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
|
||||
let frame = crate::frame::dock_frame(ctx, side);
|
||||
@@ -2027,7 +2114,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Long texts (errors, notes) wrap instead of widening the dialog.
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
let margin = horizontal_frame;
|
||||
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
|
||||
let min_w = if pattern_columns {
|
||||
540.0
|
||||
} else if wide {
|
||||
480.0
|
||||
} else {
|
||||
DIALOG_MIN_W
|
||||
} - margin;
|
||||
ui.set_min_width(min_w);
|
||||
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
|
||||
// Rows that end at the right edge line up with last frame's content width.
|
||||
@@ -2081,7 +2174,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
|
||||
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
|
||||
}
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(if pattern_columns { 1 } else { 2 }).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
if pattern_columns {
|
||||
enter |= rectangular_pattern_rows(&mut d, ui, &app.session);
|
||||
error_rows(app, ctx, &mut d, ui, true);
|
||||
return;
|
||||
}
|
||||
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
|
||||
return;
|
||||
}
|
||||
@@ -2798,24 +2896,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(d.kind, Kind::SketchModify(_))
|
||||
&& d.previews()
|
||||
&& let Err(error) = apply_commands(app, &d)
|
||||
{
|
||||
d.refuse(app, error);
|
||||
}
|
||||
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
|
||||
row_label(ui, "");
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||||
let response = ui.button("Create Issue…");
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
error_rows(app, ctx, &mut d, ui, false);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -148,6 +148,7 @@ pub mod sketch_modify_ui;
|
||||
#[cfg(test)]
|
||||
mod sketch_modify_ui_tests;
|
||||
pub mod sketch_palette;
|
||||
mod sketch_pattern_tree;
|
||||
pub(crate) mod sketch_text;
|
||||
mod sketch_text_handles;
|
||||
mod sketch_text_move;
|
||||
|
||||
@@ -64,6 +64,8 @@ fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() {
|
||||
#[test]
|
||||
fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
|
||||
let mut h = fixture();
|
||||
// Keep the picked geometry clear of the two-column rectangular-pattern form.
|
||||
step(&mut h, json!({"call":"ui.camera","params":{"target":[-60,0,0],"distance":120}}));
|
||||
step(&mut h, json!({"start":"sketch.pattern.rectangular"}));
|
||||
pick(&mut h, [-22.5, 10.0], false);
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternRect { count, count2, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
@@ -78,7 +80,7 @@ fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() {
|
||||
pick(&mut h, [-22.5, 10.0], false);
|
||||
step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}}));
|
||||
pick(&mut h, [-40.0, -20.0], false);
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
if let crate::dialogs::Kind::SketchModify(Mod::PatternCirc { count, angle, .. }) = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
*count = "4".into();
|
||||
*angle = "180 deg".into();
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, pattern_toggle, row_label};
|
||||
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
@@ -76,11 +77,20 @@ pub enum Mod {
|
||||
/// carries the copy flag, so the Create Copy row needs no engine change.
|
||||
Move { x: String, y: String, angle: String, copy: bool },
|
||||
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
|
||||
/// apart, then the same along the perpendicular direction the command derives from it.
|
||||
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
|
||||
/// apart, then the same along a second picked direction (perpendicular by default).
|
||||
PatternRect {
|
||||
count: String,
|
||||
spacing: String,
|
||||
count2: String,
|
||||
spacing2: String,
|
||||
alignment: PatternAlignment,
|
||||
alignment2: PatternAlignment,
|
||||
reversed: bool,
|
||||
reversed2: bool,
|
||||
},
|
||||
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
|
||||
/// angle they span.
|
||||
PatternCirc { count: String, angle: String },
|
||||
PatternCirc { count: String, angle: String, alignment: PatternAlignment, reversed: bool },
|
||||
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
|
||||
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
|
||||
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
|
||||
@@ -132,7 +142,11 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
|
||||
// default, the way the engine reads it.
|
||||
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Direction", AXES, false)],
|
||||
Mod::PatternRect { .. } => vec![
|
||||
SelInput::new("Entities", CURVES | SKETCH_POINTS, true),
|
||||
SelInput::new("Direction", AXES, false),
|
||||
SelInput::new("Direction 2", AXES, false),
|
||||
],
|
||||
// The centre is required: `sketch.pattern.circular` has no default for it.
|
||||
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
@@ -157,8 +171,19 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, chain: true, flip: false },
|
||||
"sketch.mirror" => Mod::Mirror,
|
||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
"sketch.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect {
|
||||
count: "3".into(),
|
||||
spacing: "20 mm".into(),
|
||||
count2: "1".into(),
|
||||
spacing2: "20 mm".into(),
|
||||
alignment: PatternAlignment::Right,
|
||||
alignment2: PatternAlignment::Right,
|
||||
reversed: false,
|
||||
reversed2: false,
|
||||
},
|
||||
"sketch.pattern.circular" => {
|
||||
Mod::PatternCirc { count: "6".into(), angle: "360 deg".into(), alignment: PatternAlignment::Right, reversed: false }
|
||||
}
|
||||
"sketch.fillet" => corner(false, 0),
|
||||
"sketch.chamfer.equal_distance" => corner(true, 0),
|
||||
"sketch.chamfer.two_distance" => corner(true, 1),
|
||||
@@ -188,12 +213,21 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
|
||||
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
|
||||
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
|
||||
let kind = match &pattern.kind {
|
||||
SketchPatternKind::Circular { count, angle, .. } => Mod::PatternCirc { count: count.to_string(), angle: format!("{angle} deg") },
|
||||
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => Mod::PatternRect {
|
||||
SketchPatternKind::Circular { count, angle, alignment, reversed, .. } => Mod::PatternCirc {
|
||||
count: count.to_string(),
|
||||
angle: format!("{angle} deg"),
|
||||
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
||||
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
||||
},
|
||||
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2, .. } => Mod::PatternRect {
|
||||
count: count.to_string(),
|
||||
spacing: format!("{spacing} mm"),
|
||||
count2: count2.to_string(),
|
||||
spacing2: format!("{spacing2} mm"),
|
||||
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
||||
alignment2: if *alignment2 == PatternAlignment::Left { PatternAlignment::Right } else { *alignment2 },
|
||||
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
||||
reversed2: *reversed2 ^ (*alignment2 == PatternAlignment::Left),
|
||||
},
|
||||
};
|
||||
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
|
||||
@@ -210,8 +244,16 @@ pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
|
||||
input.items = vec![Sel::SketchPoint { id: center.clone() }];
|
||||
}
|
||||
}
|
||||
SketchPatternKind::Rectangular { dir, .. } => {
|
||||
SketchPatternKind::Rectangular { dir, dir2, direction, direction2, .. } => {
|
||||
dialog.extra.insert("dir".into(), json!(dir));
|
||||
dialog.extra.insert("dir2".into(), json!(dir2));
|
||||
for (index, id) in [(1, direction), (2, direction2)] {
|
||||
if let Some(id) = id
|
||||
&& let Some(input) = dialog.inputs.get_mut(index)
|
||||
{
|
||||
input.items = vec![Sel::SketchCurve { id: id.clone() }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.extra.insert("pattern".into(), json!(id));
|
||||
@@ -287,19 +329,33 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
check(ui, copy);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
// Its two direction columns include both the reference picks and values.
|
||||
Mod::PatternRect { .. } => {}
|
||||
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
||||
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Alignment");
|
||||
ui.horizontal(|ui| {
|
||||
pattern_toggle(ui, "Alignment", "Flip direction", "pattern_flip", reversed);
|
||||
let mut centered = *alignment == PatternAlignment::Center;
|
||||
pattern_toggle(ui, "Alignment", "Center", "pattern_center", &mut centered);
|
||||
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
|
||||
});
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(crate::i18n::tr_label(
|
||||
"Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
|
||||
))
|
||||
.small(),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
||||
if *chamfer {
|
||||
@@ -349,13 +405,13 @@ fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option<Vec2> {
|
||||
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
|
||||
let Kind::SketchModify(k) = &d.kind else { return None };
|
||||
match k {
|
||||
Mod::PatternRect { .. } => {
|
||||
Mod::PatternRect { alignment, reversed, .. } => {
|
||||
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
|
||||
let dir = match d.inputs.get(1).and_then(|i| i.items.first()) {
|
||||
Some(sel) => sketch_dir(app, sel)?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
Some(plane.dir_to_world(dir))
|
||||
Some(plane.dir_to_world(if (*alignment == PatternAlignment::Left) ^ *reversed { -dir } else { dir }))
|
||||
}
|
||||
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
|
||||
// handle only for Revolve, through machinery tied to its own extents, so the circular
|
||||
@@ -617,7 +673,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
}
|
||||
("sketch.move", p)
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
|
||||
let entities = entity_ids(&app.session, inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
@@ -635,16 +691,24 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
if n1.saturating_mul(n2) > MAX_COPIES as u64 {
|
||||
return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances"));
|
||||
}
|
||||
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2});
|
||||
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2, "alignment": alignment, "alignment2": alignment2, "reversed": reversed, "reversed2": reversed2});
|
||||
// A picked direction that has no component in the sketch plane is reported rather
|
||||
// than quietly replaced by the default.
|
||||
if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) {
|
||||
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
|
||||
p["dir"] = json!([d.x, d.y]);
|
||||
for (index, vector_key, line_key) in [(1, "dir", "direction"), (2, "dir2", "direction2")] {
|
||||
if let Some(sel) = inputs.get(index).and_then(|i| i.items.first()) {
|
||||
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
|
||||
p[vector_key] = json!([d.x, d.y]);
|
||||
p[line_key] = match sel {
|
||||
Sel::SketchCurve { id } => json!(id),
|
||||
_ => Value::Null,
|
||||
};
|
||||
} else {
|
||||
p[line_key] = Value::Null;
|
||||
}
|
||||
}
|
||||
("sketch.pattern.rectangular", p)
|
||||
}
|
||||
Mod::PatternCirc { count, angle } => {
|
||||
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
||||
let entities = entity_ids(&app.session, inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
@@ -663,7 +727,10 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
return Err("Quantity must be at least 2".into());
|
||||
}
|
||||
let a = total_angle(app, "Total angle", angle)?;
|
||||
("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a}))
|
||||
(
|
||||
"sketch.pattern.circular",
|
||||
json!({"entities": entities, "center": center, "count": n, "angle": a, "alignment": alignment, "reversed": reversed}),
|
||||
)
|
||||
}
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
|
||||
@@ -456,7 +456,7 @@ fn open(cmd: &str) -> (crate::scenario::Harness, String) {
|
||||
|
||||
fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind {
|
||||
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2, .. }) = &mut d.kind {
|
||||
*c1 = count.into();
|
||||
*s1 = spacing.into();
|
||||
*c2 = count2.into();
|
||||
@@ -472,12 +472,58 @@ fn rectangular_pattern_window_opens_instead_of_a_click_tool() {
|
||||
assert!(h.app.tool.is_none(), "the click tool still takes the command");
|
||||
let d = h.app.dialog.as_ref().expect("the window opened");
|
||||
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction"]);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction", "Direction 2"]);
|
||||
// Nothing is committed by opening the window.
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_columns_flip_and_center_independently_and_restore_even_counts_when_editing() {
|
||||
use solidcraft_engine::sketch::PatternAlignment;
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
rect(&mut h, "4", "20", "2", "10");
|
||||
h.settle();
|
||||
let first = crate::scenario::handle_at("dialog:Spacing").unwrap();
|
||||
let second = crate::scenario::handle_at("dialog:Spacing 2").unwrap();
|
||||
assert!(second.x - first.x > 180.0, "the directions should occupy separate columns: {first:?}, {second:?}");
|
||||
assert!((second.y - first.y).abs() < 2.0, "the independent spacing fields should share a row");
|
||||
for label in ["Alignment", "Alignment 2"] {
|
||||
let left = crate::scenario::handle_at(&format!("dialog:{label}:Flip direction")).unwrap();
|
||||
let right = crate::scenario::handle_at(&format!("dialog:{label}:Center")).unwrap();
|
||||
assert!((left.y - right.y).abs() < 2.0, "alignment choices should fit on a single line: {left:?}, {right:?}");
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Flip direction"}})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let commands = apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(commands[0].1["alignment"], "center");
|
||||
assert_eq!(commands[0].1["alignment2"], "right");
|
||||
assert_eq!(commands[0].1["reversed2"], true);
|
||||
assert_eq!(commands[0].1["count"], 4);
|
||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let pattern = h.app.session.world_state().sketch(sid).unwrap().sketch.patterns[0].id.clone();
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &pattern));
|
||||
assert!(matches!(
|
||||
h.app.dialog.as_ref().unwrap().kind,
|
||||
Kind::SketchModify(Mod::PatternRect { alignment: PatternAlignment::Center, alignment2: PatternAlignment::Right, reversed2: true, .. })
|
||||
));
|
||||
rect(&mut h, "4", "20", "3", "10");
|
||||
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_ok());
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment 2:Center"}})).unwrap();
|
||||
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(commands[0].1["alignment"], "center");
|
||||
assert_eq!(commands[0].1["reversed"], true);
|
||||
assert_eq!(commands[0].1["alignment2"], "center");
|
||||
assert_eq!(commands[0].1["reversed2"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
@@ -506,6 +552,45 @@ fn a_picked_axis_becomes_an_in_plane_direction() {
|
||||
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_picked_lines_are_sent_as_live_references_and_restored_in_the_editor() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.run("sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]})).unwrap();
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "dir1".into() }];
|
||||
d.inputs[2].items = vec![Sel::SketchCurve { id: "dir2".into() }];
|
||||
rect(&mut h, "3", "20", "2", "10");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds[0].1["direction"], "dir1");
|
||||
assert_eq!(cmds[0].1["direction2"], "dir2");
|
||||
let out = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, out["pattern"].as_str().unwrap()));
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.inputs[1].items, vec![Sel::SketchCurve { id: "dir1".into() }]);
|
||||
assert_eq!(d.inputs[2].items, vec![Sel::SketchCurve { id: "dir2".into() }]);
|
||||
let reopened = apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(reopened[0].1["direction2"], "dir2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_pattern_tools_are_promoted_together_and_open_from_the_toolbar() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let panels = crate::toolbar_custom::panel_lists("SKETCH");
|
||||
let (_, cmds, promoted) = panels.iter().find(|(panel, ..)| *panel == "PATTERN").unwrap();
|
||||
let ids: Vec<_> = cmds.iter().take(*promoted).map(|c| c.id).collect();
|
||||
assert_eq!(ids, ["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"]);
|
||||
for id in ids {
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":format!("toolbar:{id}")}})).unwrap();
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::SketchModify(_))), "{id}");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
@@ -590,7 +675,7 @@ fn open_circ() -> (crate::scenario::Harness, String) {
|
||||
|
||||
fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind {
|
||||
if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a, .. }) = &mut d.kind {
|
||||
*c = count.into();
|
||||
*a = angle.into();
|
||||
} else {
|
||||
@@ -627,6 +712,41 @@ fn the_typed_count_and_total_angle_reach_the_command() {
|
||||
assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_flip_and_center_buttons_apply_and_reopen_the_saved_placement() {
|
||||
use solidcraft_engine::sketch::{PatternAlignment, SketchPatternKind};
|
||||
let (mut h, _) = open_circ();
|
||||
circ(&mut h, "6", "90 deg");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Flip direction"}})).unwrap();
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
|
||||
assert_eq!(params["count"], json!(6));
|
||||
assert_eq!(params["angle"], json!(90.));
|
||||
assert_eq!(params["alignment"], json!("center"));
|
||||
assert_eq!(params["reversed"], json!(true));
|
||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let id = {
|
||||
let state = h.app.session.world_state();
|
||||
let pattern = &state.sketch(sid).unwrap().sketch.patterns[0];
|
||||
assert!(matches!(pattern.kind, SketchPatternKind::Circular { alignment: PatternAlignment::Center, reversed: true, .. }));
|
||||
pattern.id.clone()
|
||||
};
|
||||
assert!(crate::sketch_modify_ui::edit_pattern(&mut h.app, &id));
|
||||
assert!(matches!(
|
||||
h.app.dialog.as_ref().unwrap().kind,
|
||||
Kind::SketchModify(Mod::PatternCirc { alignment: PatternAlignment::Center, reversed: true, .. })
|
||||
));
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Alignment:Center"}})).unwrap();
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let params = &apply_commands(&h.app, &d).unwrap()[0].1;
|
||||
assert_eq!(params["alignment"], json!("right"));
|
||||
assert_eq!(params["reversed"], json!(true));
|
||||
}
|
||||
|
||||
/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular`
|
||||
/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated
|
||||
/// here or it disappears in silence.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1050,7 +1050,9 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
|
||||
use solidcraft_engine::sketch::ConstraintKind::*;
|
||||
match *k {
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
|
||||
PatternPoint { p, q, center, .. } => (vec![], vec![p, q].into_iter().chain(center).collect()),
|
||||
PatternPoint { p, q, center, directions, .. } => {
|
||||
(directions.into_iter().flatten().map(|(l, _)| l).collect(), vec![p, q].into_iter().chain(center).collect())
|
||||
}
|
||||
PatternRadius { a, b } => (vec![a, b], vec![]),
|
||||
Fix { p } => (vec![], vec![p]),
|
||||
Midpoint { p, l } => (vec![l], vec![p]),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(
|
||||
tab,
|
||||
),
|
||||
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
|
||||
("SKETCH", "PATTERN") => (&["sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"], 3, tab),
|
||||
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
|
||||
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
|
||||
("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{"note":"Each column has independent spacing, quantity and alignment. Center keeps the original in the middle; Left follows the negative second guide."},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
|
||||
{"click":{"sketch":[7,5]}},
|
||||
{"click":{"handle":"dialog:Direction"}},
|
||||
{"click":{"sketch":[10,-10]}},
|
||||
{"click":{"handle":"dialog:Direction 2"}},
|
||||
{"click":{"sketch":[6,-22]}},
|
||||
{"click":{"handle":"dialog:Quantity"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"3"},
|
||||
{"click":{"handle":"dialog:Spacing"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"20"},
|
||||
{"click":{"handle":"dialog:Quantity 2"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"2"},
|
||||
{"click":{"handle":"dialog:Spacing 2"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"10"},
|
||||
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
|
||||
{"click":{"handle":"dialog:Alignment:Center"}},
|
||||
{"click":{"handle":"dialog:Alignment 2:Flip direction"}},
|
||||
{"shot":"sketch_pattern_alignment_preview"},
|
||||
{"click":{"handle":"dialog:OK"}},
|
||||
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":8}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-21}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":-3}}},
|
||||
{"call":"ui.view","params":{"view":"fit"}},
|
||||
{"shot":"sketch_pattern_alignment_before"},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-19}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":0}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":-21}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":2}}},
|
||||
{"expect":{"errors":0,"sketch_curves":{"own":8}}},
|
||||
{"shot":"sketch_pattern_alignment_after"}
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{"note":"Six total holes span 90 degrees around the original; Flip chooses the extra side without moving the seed."},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[40,0],"radius":2,"id":"seed"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.point","params":{"point":[0,0],"id":"axis_center"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["axis_center"]}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"call":"ui.camera","params":{"target":[25,0,0],"distance":120}},
|
||||
{"start":"sketch.pattern.circular"},
|
||||
{"click":{"sketch":[42,0]}},
|
||||
{"click":{"handle":"dialog:Center"}},
|
||||
{"click":{"sketch":[0,0]}},
|
||||
{"click":{"handle":"dialog:Quantity"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"6"},
|
||||
{"click":{"handle":"dialog:Total angle"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"90 deg"},
|
||||
{"click":{"handle":"dialog:Alignment:Center"}},
|
||||
{"shot":"sketch_pattern_circular_center_before_flip"},
|
||||
{"click":{"handle":"dialog:Alignment:Flip direction"}},
|
||||
{"shot":"sketch_pattern_circular_center_after_flip"},
|
||||
{"click":{"handle":"dialog:OK"}},
|
||||
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":6}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",0],"value":23.511410091698927}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",1],"value":-32.3606797749979}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",0,"center_at",0],"value":40}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",0,"center_at",1],"value":0}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[40,0],"to":[45,0]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",0],"value":26.45033635316129}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",5,"center_at",1],"value":-36.405764746872634}}},
|
||||
{"shot":"sketch_pattern_circular_center_source_edit"}
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
[
|
||||
{"note":"Even centered totals keep the original fixed; Flip chooses the extra side independently for each direction."},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
|
||||
{"click":{"sketch":[7,5]}},
|
||||
{"click":{"handle":"dialog:Direction"}},
|
||||
{"click":{"sketch":[10,-10]}},
|
||||
{"click":{"handle":"dialog:Direction 2"}},
|
||||
{"click":{"sketch":[6,-22]}},
|
||||
{"click":{"handle":"dialog:Quantity"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"4"},
|
||||
{"click":{"handle":"dialog:Spacing"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"20"},
|
||||
{"click":{"handle":"dialog:Quantity 2"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"2"},
|
||||
{"click":{"handle":"dialog:Spacing 2"}},
|
||||
{"key":"A","cmd":true},
|
||||
{"text":"10"},
|
||||
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
|
||||
{"click":{"handle":"dialog:Alignment:Center"}},
|
||||
{"click":{"handle":"dialog:Alignment 2:Center"}},
|
||||
{"click":{"handle":"dialog:Alignment:Flip direction"}},
|
||||
{"shot":"sketch_pattern_even_center_preview"},
|
||||
{"click":{"handle":"dialog:OK"}},
|
||||
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":10}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-29}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":13}}},
|
||||
{"call":"ui.view","params":{"view":"fit"}},
|
||||
{"shot":"sketch_pattern_even_center_before"},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-27}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":16}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",0],"value":-25}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",9,"center_at",1],"value":14}}},
|
||||
{"expect":{"errors":0,"sketch_curves":{"own":10}}},
|
||||
{"shot":"sketch_pattern_even_center_after"}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{"note":"The PATTERN toolbar opens a linear pattern with two independent picked direction lines. Copies follow the original and both lines."},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[5,5],"radius":2,"id":"seed"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-10],[20,-10]],"ids":["guide1"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,-30],[12,-14]],"ids":["guide2"]}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide1","guide2"],"fixed":true}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"click":{"handle":"toolbar:sketch.pattern.rectangular"}},
|
||||
{"click":{"sketch":[7,5]}},
|
||||
{"click":{"handle":"dialog:Direction"}},
|
||||
{"click":{"sketch":[10,-10]}},
|
||||
{"click":{"handle":"dialog:Direction 2"}},
|
||||
{"click":{"sketch":[6,-22]}},
|
||||
{"click":{"handle":"dialog:Quantity"}}, {"key":"A","cmd":true}, {"text":"3"},
|
||||
{"click":{"handle":"dialog:Spacing"}}, {"key":"A","cmd":true}, {"text":"20"},
|
||||
{"click":{"handle":"dialog:Quantity 2"}}, {"key":"A","cmd":true}, {"text":"2"},
|
||||
{"click":{"handle":"dialog:Spacing 2"}}, {"key":"A","cmd":true}, {"text":"10"},
|
||||
{"expect":{"dialog":"PatternRect","errors":0,"sketch_curves":{"own":3}}},
|
||||
{"shot":"sketch_pattern_two_directions_preview"},
|
||||
{"click":{"handle":"dialog:OK"}},
|
||||
{"expect":{"dialog":null,"errors":0,"sketch_curves":{"own":8}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":51}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":13}}},
|
||||
{"call":"ui.view","params":{"view":"fit"}},
|
||||
{"shot":"sketch_pattern_two_directions_before"},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"seed.center","from":[5,5],"to":[7,8]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":53}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":16}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["seed"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2"],"fixed":false}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["guide2.start"],"fixed":true}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.drag","params":{"entity":"guide2.end","from":[12,-14],"to":[16,-18]}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",0],"value":55}}},
|
||||
{"expect":{"query":{"command":"sketch.inspect","path":["curves",7,"center_at",1],"value":14}}},
|
||||
{"expect":{"errors":0,"sketch_curves":{"own":8}}},
|
||||
{"shot":"sketch_pattern_two_directions_after"}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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}`.
|
||||
Reference in new issue
Block a user