diff --git a/assets/locales/cs.json b/assets/locales/cs.json index 619f6194..63d89a77 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -82,6 +82,7 @@ "Density": "Hustota", "Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.", "Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.", + "Direction": "Směr", "Discard": "Zahodit", "Do not export hidden edges": "Neexportovat skryté hrany", "Double-click to rename": "Dvojklikem přejmenujete", @@ -121,6 +122,7 @@ "Home view": "Výchozí pohled", "In This Design": "V tomto návrhu", "In place (surfaces)": "Na místě (plochy)", + "In plane": "V rovině", "Inside": "Uvnitř", "Interferes": "Koliduje", "Issue title": "Název hlášení", @@ -154,6 +156,7 @@ "No matching appearances": "Žádné odpovídající vzhledy", "No visible bodies to export.": "Žádná viditelná tělesa k exportu.", "None": "Žádné", + "Normal to sketch": "Kolmo ke skice", "Not available yet": "Zatím není k dispozici", "Notch": "Zářez", "Opacity": "Neprůhlednost", diff --git a/assets/locales/de.json b/assets/locales/de.json index 58467f62..9a928888 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -26,6 +26,7 @@ "Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)", "Current perspective view": "Aktuelle perspektivische Ansicht", "Curvature (G2)": "Krümmung (G2)", + "Direction": "Richtung", "Do not export hidden edges": "Verdeckte Kanten nicht exportieren", "Don't ask again": "Nicht mehr fragen", "Drive Joint": "Gelenk antreiben", @@ -47,6 +48,7 @@ "Holes": "Löcher", "Holes (edges on them)": "Löcher (ihre Kanten)", "In place (surfaces)": "An Ort und Stelle (Flächen)", + "In plane": "In der Ebene", "Inside": "Innen", "Length": "Länge", "Max": "Max", @@ -55,6 +57,7 @@ "New study": "Neue Untersuchung", "No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.", "None": "Keine", + "Normal to sketch": "Normal zur Skizze", "Notch": "Ausklinkung", "Outside": "Außen", "Overall length": "Gesamtlänge", diff --git a/assets/locales/en.json b/assets/locales/en.json index 18130fac..d03d1318 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -86,6 +86,7 @@ "Density": "Density", "Designs you open or save appear here.": "Designs you open or save appear here.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.", + "Direction": "Direction", "Discard": "Discard", "Do not export hidden edges": "Do not export hidden edges", "Don't ask again": "Don't ask again", @@ -128,6 +129,7 @@ "Home view": "Home view", "In This Design": "In This Design", "In place (surfaces)": "In place (surfaces)", + "In plane": "In plane", "Inside": "Inside", "Interferes": "Interferes", "Issue title": "Issue title", @@ -163,6 +165,7 @@ "No matching appearances": "No matching appearances", "No visible bodies to export.": "No visible bodies to export.", "None": "None", + "Normal to sketch": "Normal to sketch", "Not available yet": "Not available yet", "Notch": "Notch", "Opacity": "Opacity", diff --git a/assets/locales/es.json b/assets/locales/es.json index e312d1f2..411cfecd 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -82,6 +82,7 @@ "Density": "Densidad", "Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.", + "Direction": "Dirección", "Discard": "Descartar", "Do not export hidden edges": "No exportar aristas ocultas", "Double-click to rename": "Doble clic para cambiar el nombre", @@ -120,6 +121,7 @@ "Home view": "Vista de inicio", "In This Design": "En este diseño", "In place (surfaces)": "En su posición (superficies)", + "In plane": "En el plano", "Inside": "Interior", "Interferes": "Interfiere", "Issue title": "Título de la incidencia", @@ -153,6 +155,7 @@ "No matching appearances": "No hay apariencias coincidentes", "No visible bodies to export.": "No hay cuerpos visibles para exportar.", "None": "Ninguno", + "Normal to sketch": "Normal al boceto", "Not available yet": "Todavía no disponible", "Notch": "Muesca", "Opacity": "Opacidad", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 9610dcb8..0809c65e 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -86,6 +86,7 @@ "Density": "Masse volumique", "Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.", "Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.", + "Direction": "Direction", "Discard": "Abandonner", "Do not export hidden edges": "Ne pas exporter les arêtes cachées", "Don't ask again": "Ne plus demander", @@ -127,6 +128,7 @@ "Home view": "Vue d'accueil", "In This Design": "Dans cette conception", "In place (surfaces)": "En place (surfaces)", + "In plane": "Dans le plan", "Inside": "Intérieur", "Interferes": "Interfère", "Issue title": "Titre du ticket", @@ -161,6 +163,7 @@ "No matching appearances": "Aucune apparence correspondante", "No visible bodies to export.": "Aucun corps visible à exporter.", "None": "Aucun", + "Normal to sketch": "Normale à l'esquisse", "Not available yet": "Pas encore disponible", "Notch": "Encoche", "Opacity": "Opacité", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index 725f3d9c..a557744a 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -82,6 +82,7 @@ "Density": "Densidade", "Designs you open or save appear here.": "Os deseños que abras ou gardes aparecen aquí.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Ata o obxecto. Escolle Ángulo para editar a extensión angular.", + "Direction": "Dirección", "Discard": "Descartar", "Do not export hidden edges": "Non exportar as arestas ocultas", "Double-click to rename": "Dobre clic para renomear", @@ -121,6 +122,7 @@ "Home view": "Vista de inicio", "In This Design": "Neste deseño", "In place (surfaces)": "No sitio (superficies)", + "In plane": "No plano", "Inside": "Dentro", "Interferes": "Interfire", "Issue title": "Título da incidencia", @@ -154,6 +156,7 @@ "No matching appearances": "Non hai aparencias coincidentes", "No visible bodies to export.": "Non hai corpos visibles para exportar.", "None": "Ningún", + "Normal to sketch": "Normal ao bosquexo", "Not available yet": "Aínda non dispoñible", "Notch": "Entalle", "Opacity": "Opacidade", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 22f99eb1..1799c46f 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -86,6 +86,7 @@ "Density": "密度", "Designs you open or save appear here.": "開いたデザインや保存したデザインがここに表示されます。", "Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。", + "Direction": "方向", "Discard": "破棄", "Do not export hidden edges": "出力しない", "Don't ask again": "今後確認しない", @@ -128,6 +129,7 @@ "Home view": "ホームビュー", "In This Design": "このデザイン内", "In place (surfaces)": "その場で(サーフェス)", + "In plane": "平面内", "Inside": "内側", "Interferes": "干渉あり", "Issue title": "Issueタイトル", @@ -163,6 +165,7 @@ "No matching appearances": "一致する外観がありません", "No visible bodies to export.": "出力する表示中のボディがありません。", "None": "なし", + "Normal to sketch": "スケッチに垂直", "Not available yet": "まだ利用できません", "Notch": "ノッチ", "Opacity": "不透明度", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 928f4b65..47589bae 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -26,6 +26,7 @@ "Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)", "Current perspective view": "현재 원근 투영 뷰", "Curvature (G2)": "곡률 (G2)", + "Direction": "방향", "Do not export hidden edges": "숨겨진 모서리 내보내지 않기", "Don't ask again": "다시 묻지 않기", "Drive Joint": "조인트 구동", @@ -47,6 +48,7 @@ "Holes": "구멍", "Holes (edges on them)": "구멍 (구멍의 모서리)", "In place (surfaces)": "제자리에서 (서피스)", + "In plane": "평면 내", "Inside": "안쪽", "Length": "길이", "Max": "최대", @@ -55,6 +57,7 @@ "New study": "새 연구", "No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.", "None": "없음", + "Normal to sketch": "스케치에 수직", "Notch": "노치", "Outside": "바깥쪽", "Overall length": "전체 길이", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index c45e1a6a..f51f6a25 100644 --- a/assets/locales/pt-BR.json +++ b/assets/locales/pt-BR.json @@ -82,6 +82,7 @@ "Density": "Densidade", "Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.", + "Direction": "Direção", "Discard": "Descartar", "Do not export hidden edges": "Não exportar arestas ocultas", "Double-click to rename": "Clique duas vezes para renomear", @@ -121,6 +122,7 @@ "Home view": "Vista inicial", "In This Design": "Neste projeto", "In place (surfaces)": "No lugar (superfícies)", + "In plane": "No plano", "Inside": "Dentro", "Interferes": "Interfere", "Issue title": "Título da issue", @@ -154,6 +156,7 @@ "No matching appearances": "Nenhuma aparência correspondente", "No visible bodies to export.": "Nenhum corpo visível para exportar.", "None": "Nenhum", + "Normal to sketch": "Normal ao esboço", "Not available yet": "Ainda não disponível", "Notch": "Entalhe", "Opacity": "Opacidade", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index b84919c1..86d06c3a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -76,6 +76,7 @@ "Density": "Плотность", "Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.", "Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.", + "Direction": "Направление", "Discard": "Отменить", "Double-click to rename": "Двойной щелчок — переименовать", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего", @@ -106,6 +107,7 @@ "Height": "Высота", "Home view": "Главный вид", "In This Design": "В этом дизайне", + "In plane": "В плоскости", "Interferes": "Пересекается", "Issue title": "Заголовок Issue", "Joint Origin1": "Точка сопряжения1", @@ -134,6 +136,7 @@ "New study": "Новое исследование", "No crash reports.": "Нет отчётов о сбоях.", "No matching appearances": "Нет подходящих внешних видов", + "Normal to sketch": "По нормали к эскизу", "Not available yet": "Пока недоступно", "Notch": "Вырез", "Opacity": "Непрозрачность", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 001f671b..87c48b3f 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -80,6 +80,7 @@ "Density": "Yoğunluk", "Designs you open or save appear here.": "Açtığınız veya kaydettiğiniz tasarımlar burada görünür.", "Determined by To Object. Choose Angle to edit the angular extent.": "Nesneye kadar seçeneğiyle belirlenir. Açısal kapsamı düzenlemek için Açı seçeneğini seçin.", + "Direction": "Yön", "Discard": "At", "Double-click to rename": "Yeniden adlandırmak için çift tıklayın", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Görünümde orta tuşla çift tıklama: Başlangıç (gösterileni çerçeveleyen başlangıç görünümü), Sığdır (yönü koruyarak gösterileni çerçeveler) veya hiçbir şey", @@ -114,6 +115,7 @@ "Home view": "Başlangıç görünümü", "In This Design": "Bu tasarımda", "In place (surfaces)": "Yerinde (yüzeyler)", + "In plane": "Düzlemde", "Inside": "İç", "Interferes": "Girişim var", "Issue title": "Sorun başlığı", @@ -147,6 +149,7 @@ "No crash reports.": "Çökme raporu yok.", "No matching appearances": "Eşleşen görünüm yok", "None": "Hiçbiri", + "Normal to sketch": "Eskize dik", "Not available yet": "Henüz kullanılamıyor", "Opacity": "Opaklık", "Open GitHub draft": "GitHub taslağını aç", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index b1e3ae32..41461393 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -86,6 +86,7 @@ "Density": "Густина", "Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.", "Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.", + "Direction": "Напрямок", "Discard": "Відхилити", "Do not export hidden edges": "Не експортувати приховані ребра", "Double-click to rename": "Двічі клацніть, щоб перейменувати", @@ -126,6 +127,7 @@ "Home view": "Головний вид", "In This Design": "У цьому проєкті", "In place (surfaces)": "На місці (поверхні)", + "In plane": "У площині", "Inside": "Усередину", "Interferes": "Перетинається", "Issue title": "Заголовок повідомлення", @@ -160,6 +162,7 @@ "No matching appearances": "Немає відповідних виглядів", "No visible bodies to export.": "Немає видимих тіл для експорту.", "None": "Немає", + "Normal to sketch": "За нормаллю до ескізу", "Not available yet": "Ще недоступно", "Notch": "Виріз", "Opacity": "Непрозорість", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index a4d0a2ab..37937646 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -86,6 +86,7 @@ "Density": "密度", "Designs you open or save appear here.": "你打开或保存的设计会显示在此处。", "Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。", + "Direction": "方向", "Discard": "放弃", "Do not export hidden edges": "不导出隐藏线", "Don't ask again": "不再询问", @@ -128,6 +129,7 @@ "Home view": "主视图", "In This Design": "在此设计中", "In place (surfaces)": "原位(曲面)", + "In plane": "在平面内", "Inside": "内侧", "Interferes": "发生干涉", "Issue title": "Issue 标题", @@ -163,6 +165,7 @@ "No matching appearances": "没有匹配的外观", "No visible bodies to export.": "没有可导出的可见实体。", "None": "无", + "Normal to sketch": "垂直于草图", "Not available yet": "暂不可用", "Notch": "缺口", "Opacity": "不透明度", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3a88a021..426a9c20 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -901,7 +901,8 @@ pub enum FeatureKind { targets: Vec, }, /// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from - /// the curves toward the body (to the next face, or `depth`). A web makes one per curve. + /// the curves toward the body (to the next face, or `depth`), or with `normal` along the + /// sketch normal. A web makes one per curve. Rib { sketch: SketchId, curves: Vec, @@ -917,6 +918,10 @@ pub enum FeatureKind { /// Web: each curve is its own wall. #[serde(default, skip_serializing_if = "std::ops::Not::not")] web: bool, + /// Grow the wall along the sketch normal (thickness across the curves, in the sketch + /// plane) instead of across the plane from the curves. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + normal: bool, }, /// Move planar faces (at the given points) onto a target plane parallel to them. ReplaceFace { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 3ade16e1..3b445648 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -69,7 +69,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets)) } - FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => { + FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => { let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); let t = val(vals, thickness, Kind::Length)?; if !(t > 0.0) { @@ -88,7 +88,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } let mut tools = Vec::new(); for g in &groups { - tools.push(rib_tool(st, &ss, g, t, depth, *flip)?); + tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? }); } apply_op(st, f, tools, Operation::Join, &[]) } @@ -286,6 +286,83 @@ fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: O tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into())) } +/// A rib wall along the sketch normal: the curves widened to `t` in the sketch plane, run from +/// the sketch toward the body by `depth` (or until the whole wall reaches the body). +fn rib_tool_normal(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option, flip: bool) -> Result { + let pts = chain_points(ss, ids)?; + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + if !(len > 1e-9) { + return Err(DocError::Invalid("a zero-length rib curve".into())); + } + let samples = ((len / (t * 0.5).max(len / 400.0)).ceil() as usize).clamp(2, 400); + let along: Vec = (0..=samples).map(|k| ss.plane.to_world(point_on_polyline(&pts, len * k as f64 / samples as f64))).collect(); + let normal = ss.plane.normal(); + let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len())); + let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q)); + // Where the body begins from each point of the curves, along `sign` × the sketch normal. + let limit = reach * 2.0 + 1.0; + let step = (limit / 400.0).max(1e-3); + let hits = |sign: f64| -> Vec { + let mut out = Vec::new(); + for q in &along { + let Some(k) = (1..=400).find(|k| inside(*q + normal * (sign * *k as f64 * step))) else { continue }; + let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step); + for _ in 0..30 { + let m = 0.5 * (lo + hi); + if inside(*q + normal * (sign * m)) { hi = m } else { lo = m } + } + out.push(hi); + } + out + }; + // Toward the material: the side of the sketch where the curves meet the bodies (unless flipped). + let (front, back) = (hits(1.0), hits(-1.0)); + let mut sign = if back.len() > front.len() { -1.0 } else { 1.0 }; + let mut reached = if sign > 0.0 { front } else { back }; + if flip { + sign = -sign; + reached = hits(sign); + } + let h = match depth { + Some(d) => d, + None => { + let far = reached.iter().copied().fold(f64::NAN, f64::max); + if !far.is_finite() { + return Err(DocError::Invalid("the rib curves do not face a body along the sketch normal (give a depth)".into())); + } + far + (t * 0.5).max(step) + } + }; + // The curves widened to `t`: each point offset by half the thickness along the miter of its + // neighbours, both sides joined into one band. + let half = t / 2.0; + let mut left = Vec::with_capacity(pts.len()); + let mut right = Vec::with_capacity(pts.len()); + for (i, p) in pts.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|j| pts.get(j)).and_then(|q| (*p - *q).normalized()); + let next = pts.get(i + 1).and_then(|q| (*q - *p).normalized()); + let (n, scale) = match (prev, next) { + (Some(a), Some(b)) => { + let m = (a.perp() + b.perp()).normalized().unwrap_or(a.perp()); + (m, 1.0 / m.dot(a.perp()).max(0.25)) + } + (Some(a), None) | (None, Some(a)) => (a.perp(), 1.0), + (None, None) => continue, + }; + left.push(*p + n * (half * scale)); + right.push(*p - n * (half * scale)); + } + left.extend(right.into_iter().rev()); + let mut lp = Loop2::polygon(&left); + if lp.signed_area() < 0.0 { + lp = lp.reversed(); + } + let region = Region2 { outer: lp, holes: Vec::new() }; + let (a, b) = if sign > 0.0 { (0.0, h) } else { (-h, 0.0) }; + let tools = kernel::extrude(&ss.plane, &[region], a, b)?; + tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into())) +} + /// The point `d` along a polyline. fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { let mut left = d.max(0.0); diff --git a/crates/doc/src/format/pending/rib-normal-direction.rs b/crates/doc/src/format/pending/rib-normal-direction.rs new file mode 100644 index 00000000..1f65c799 --- /dev/null +++ b/crates/doc/src/format/pending/rib-normal-direction.rs @@ -0,0 +1,4 @@ +//! A Rib or Web can grow along the sketch normal (`normal: true`): the wall is `thickness` wide +//! across its curves in the sketch plane and runs from the sketch toward the body. Older designs +//! need no transformation: a rib without `normal` grows across the sketch plane as before; 0.14.x +//! asks for an update instead of building a normal rib in the plane. diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 7a255925..2892d68b 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.rib", "Rib", rib) .at("SOLID", "CREATE") .icon("rib") - .params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"), + .params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane, or across the curves when normal); depth? (default: to the next face); flip?: bool (fill to the other side); direction?: in_plane|normal (grow across the sketch plane, the default, or along its normal)"), CommandSpec::new("solid.web", "Web", web) .at("SOLID", "CREATE") .icon("web") - .params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"), + .params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool; direction?: in_plane|normal"), CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") .icon("emboss") @@ -133,7 +133,12 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result { check_expr(s, d, Kind::Length, cmd, "depth")?; } let flip = bool_(p, "flip").unwrap_or(false); - add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web }) + let normal = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() { + None | Some("in_plane" | "plane") => false, + Some("normal") => true, + Some(o) => return Err(bad(cmd, format!("unknown direction `{o}` (in_plane or normal)"))), + }; + add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web, normal }) } fn rib(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index f21bd390..5db4403e 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -77,6 +77,28 @@ fn rib_with_a_depth_and_web() { assert!(e.is_err_and(|e| e.to_string().contains("do not face a body"))); } +#[test] +fn rib_along_the_sketch_normal_reaches_the_body() { + // A 20 x 20 x 10 box; a line along X on a sketch 10 in front of it (y = -10), whose plane + // the box does not cross, so only a rib along the sketch normal can reach it. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [0, 0, 0]})); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Front"})); + run(&mut s, "sketch.line", json!({"points": [[2, 5], [18, 5]], "ids": ["l1"]})); + run(&mut s, "sketch.finish", json!({})); + let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2})); + assert!(e.is_err_and(|e| e.to_string().contains("do not face a body"))); + run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal"})); + // 16 long, 2 thick, 10 from the sketch to the box. + assert!(rel(volume(&mut s), 4000.0 + 320.0) < 1e-6, "{}", volume(&mut s)); + assert_eq!(measure(&mut s)["body_count"], 1); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal", "depth": 4})); + assert!(rel(volume(&mut s), 4000.0 + 128.0) < 1e-6, "{}", volume(&mut s)); + let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "sideways"})); + assert!(e.is_err_and(|e| e.to_string().contains("unknown direction"))); +} + #[test] fn emboss_and_deboss_on_a_face() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b4ef0387..0a255a2e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,8 @@ pub enum Kind { thickness: String, depth: String, flip: bool, + /// Grow along the sketch normal rather than across the plane. + normal: bool, }, /// Raise or sink profiles of a sketch on a face. Emboss { @@ -491,7 +493,7 @@ impl Dialog { Dialog::new(Kind::Mirror, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Mirror Plane", PLANES | PLANAR_FACES, false)]) } "solid.rib" | "solid.web" => Dialog::new( - Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false }, + Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false, normal: false }, vec![SelInput::new("Curves", CURVES, true)], ), "solid.emboss" => Dialog::new( @@ -2701,7 +2703,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } Kind::Split | Kind::ReplaceFace | Kind::Remove | Kind::Midplane => {} - Kind::Rib { thickness, depth, flip, .. } => { + Kind::Rib { thickness, depth, flip, normal, .. } => { + row_label(ui, "Direction"); + let mut i = usize::from(*normal); + combo(ui, "rib_direction", &["In plane", "Normal to sketch"], &mut i); + *normal = i == 1; + ui.end_row(); row_label(ui, "Thickness"); enter |= field(ui, thickness); ui.end_row(); @@ -3977,11 +3984,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S }; ("construct.plane.at_angle", json!({"base": base, "axis": axis, "angle": angle})) } - Kind::Rib { web, thickness, depth, flip } => { + Kind::Rib { web, thickness, depth, flip, normal } => { need(0, "open sketch curves")?; let curves = curve_ids(d, 0); let sketch = curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?; let mut p = json!({"sketch": sketch, "curves": curves, "thickness": thickness, "flip": flip}); + if *normal { + p["direction"] = json!("normal"); + } if !depth.trim().is_empty() { p["depth"] = json!(depth); } @@ -5204,13 +5214,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Rib { curves, thickness, depth, flip, web, normal, .. } => { let mut d = start(if *web { "solid.web" } else { "solid.rib" })?; d.kind = Kind::Rib { web: *web, thickness: thickness.to_string(), depth: depth.as_deref().map(str::to_string).unwrap_or_default(), flip: *flip, + normal: *normal, }; if let Some(inp) = d.inputs.first_mut() { inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();