From 2a4907884b0fd02c7eea048159aa34ee640cc497 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Wed, 14 Oct 2026 21:44:56 -0400 Subject: [PATCH] Ruled Surface: strips off an edge chain, exact where possible (#762) (#1151) Ruled Surface: strips off an edge chain in normal, tangent, angle or direction mode, exact planes/cylinders/cones where possible, mitred or round corners, stitched into one surface body. SurfaceRuled is noted under format 21. Closes #762. --- assets/locales/de.json | 7 +- assets/locales/en.json | 6 +- assets/locales/ja.json | 6 +- assets/locales/ko.json | 7 +- assets/locales/zh.json | 6 +- crates/doc/src/document.rs | 61 ++ crates/doc/src/eval.rs | 1 + crates/doc/src/eval_surface.rs | 29 +- crates/doc/src/feature_geometry.rs | 11 + crates/doc/src/feature_refs.rs | 1 + crates/doc/src/feature_walk_tests/fixtures.rs | 5 + .../src/feature_walk_tests/golden_exprs.txt | 5 + .../feature_walk_tests/golden_geometry.txt | 3 + .../src/feature_walk_tests/golden_refs.txt | 2 + crates/doc/src/format.rs | 3 + crates/doc/src/params.rs | 6 + crates/engine/src/cmd/surface.rs | 109 ++- crates/kernel/src/lib.rs | 2 + crates/kernel/src/surface_ruled.rs | 880 ++++++++++++++++++ crates/kernel/src/surfaces.rs | 71 +- crates/ui-egui/src/dialogs_surface.rs | 111 ++- crates/ui-egui/src/preselect_tests.rs | 40 + crates/ui-egui/src/timeline.rs | 1 + docs/features.md | 6 +- 24 files changed, 1343 insertions(+), 36 deletions(-) create mode 100644 crates/kernel/src/surface_ruled.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index ba9c920e..298b649a 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -851,5 +851,10 @@ "In place (surfaces)": "An Ort und Stelle (Flächen)", "None": "Keine", "Picked": "Ausgewählte", - "Restore outer boundary": "Äußere Begrenzung wiederherstellen" + "Restore outer boundary": "Äußere Begrenzung wiederherstellen", + "RULED SURFACE": "REGELFLÄCHE", + "Mitre": "Gehrung", + "Reference face": "Referenzfläche", + "Round": "Rund", + "Type": "Typ" } diff --git a/assets/locales/en.json b/assets/locales/en.json index 50eb2f6f..a72660a5 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1109,5 +1109,9 @@ "In place (surfaces)": "In place (surfaces)", "None": "None", "Picked": "Picked", - "Restore outer boundary": "Restore outer boundary" + "Restore outer boundary": "Restore outer boundary", + "RULED SURFACE": "RULED SURFACE", + "Mitre": "Mitre", + "Reference face": "Reference face", + "Round": "Round" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 913316f0..0ab3584f 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1109,5 +1109,9 @@ "In place (surfaces)": "その場で(サーフェス)", "None": "なし", "Picked": "選択したもの", - "Restore outer boundary": "外側の境界を復元" + "Restore outer boundary": "外側の境界を復元", + "RULED SURFACE": "ルールドサーフェス", + "Mitre": "留め継ぎ", + "Reference face": "参照面", + "Round": "丸め" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 99531df0..59d74d9e 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -851,5 +851,10 @@ "In place (surfaces)": "제자리에서 (서피스)", "None": "없음", "Picked": "선택한 항목", - "Restore outer boundary": "외부 경계 복원" + "Restore outer boundary": "외부 경계 복원", + "RULED SURFACE": "룰드 서피스", + "Mitre": "연귀", + "Reference face": "참조 면", + "Round": "둥글게", + "Type": "유형" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 6e5cb39a..31e819d2 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1109,5 +1109,9 @@ "In place (surfaces)": "原位(曲面)", "None": "无", "Picked": "所选", - "Restore outer boundary": "恢复外边界" + "Restore outer boundary": "恢复外边界", + "RULED SURFACE": "直纹曲面", + "Mitre": "斜接", + "Reference face": "参考面", + "Round": "圆角" } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0c4ea839..ea1b7399 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -138,6 +138,44 @@ pub enum Direction { Symmetric, } +/// The direction a ruled surface leaves its edges (`surface.ruled`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuledMode { + /// Along the normal of the face beside each edge. + #[default] + Normal, + /// Continuing the face beside each edge. + Tangent, + /// At an angle from a reference direction (the face's normal unless one is given). + Angle, + /// Along a fixed direction. + Direction, +} + +impl RuledMode { + fn is_normal(&self) -> bool { + *self == RuledMode::Normal + } +} + +/// How a ruled surface's strips meet at sharp corners of its edge chain. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuledCorners { + /// Extended or cut back until they meet. + #[default] + Mitre, + /// Joined by a sector (inside corners are cut back). + Round, +} + +impl RuledCorners { + fn is_mitre(&self) -> bool { + *self == RuledCorners::Mitre + } +} + /// Angular starts in older designs used the raw axis sign. New revolves measure starts /// along the same sketch-normal sweep direction as the kernel. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -1078,6 +1116,27 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] tolerance: Option, }, + /// A strip of ruled surface `distance` wide hanging off the chain of `body`'s edges through + /// `edges`, as a new surface body: along `mode` (`angle` from `direction`, or from the + /// face's normal; along `direction`), from the face at `face` where it borders the chain; + /// `flip` puts it on the other side; sharp corners as `corners` says. + SurfaceRuled { + body: String, + edges: Vec, + distance: String, + #[serde(default, skip_serializing_if = "RuledMode::is_normal")] + mode: RuledMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + angle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + direction: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + #[serde(default, skip_serializing_if = "RuledCorners::is_mitre")] + corners: RuledCorners, + #[serde(default, skip_serializing_if = "Option::is_none")] + face: Option, + }, /// A planar surface body extended outward by `distance` past the edges through `edges`. SurfaceExtend { body: String, @@ -1455,6 +1514,7 @@ impl FeatureKind { FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", FeatureKind::SurfaceExtend { .. } => "ExtendFeature", + FeatureKind::SurfaceRuled { .. } => "RuledSurfaceFeature", FeatureKind::Unstitch { .. } => "UnstitchFeature", FeatureKind::ReverseNormal { .. } => "ReverseNormalFeature", FeatureKind::SurfaceUntrim { .. } => "UntrimFeature", @@ -1530,6 +1590,7 @@ impl FeatureKind { FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", FeatureKind::SurfaceExtend { .. } => "Extend", + FeatureKind::SurfaceRuled { .. } => "RuledSurface", FeatureKind::Unstitch { .. } => "Unstitch", FeatureKind::ReverseNormal { .. } => "ReverseNormal", FeatureKind::SurfaceUntrim { .. } => "Untrim", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 68a3c4ab..5cad78cd 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3926,6 +3926,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SurfaceSweep { .. } | FeatureKind::SurfaceLoft { .. } | FeatureKind::SurfaceOffset { .. } + | FeatureKind::SurfaceRuled { .. } | FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 8a8aa50a..e0cc4fff 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -1,8 +1,9 @@ //! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude, -//! Revolve, Sweep and Loft, Offset, Untrim, Unstitch and Reverse Normal (kernel `surfaces`, -//! `surface_offset`, `surface_untrim`). +//! Revolve, Sweep and Loft, Offset, Ruled, Untrim, Unstitch and Reverse Normal (kernel `surfaces`, +//! `surface_offset`, `surface_ruled`, `surface_untrim`). use super::*; +use crate::{RuledCorners, RuledMode}; use solidcraft_geom::Seg2; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { @@ -234,6 +235,30 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st.bodies.push(ModelBody::traced_new(name, made, f.id)); Ok(()) } + FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip, corners, face } => { + let d = val(vals, distance, Kind::Length)?; + let mode = match mode { + RuledMode::Normal => kernel::RuledMode::Normal, + RuledMode::Tangent => kernel::RuledMode::Tangent, + RuledMode::Angle => { + let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?; + kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction } + } + RuledMode::Direction => kernel::RuledMode::Direction( + direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?, + ), + }; + let corners = match corners { + RuledCorners::Mitre => kernel::RuledCorners::Mitre, + RuledCorners::Round => kernel::RuledCorners::Round, + }; + let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face }; + let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]); + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::traced_new(name, made, f.id)); + Ok(()) + } FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => { let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; diff --git a/crates/doc/src/feature_geometry.rs b/crates/doc/src/feature_geometry.rs index 22eda750..37133bec 100644 --- a/crates/doc/src/feature_geometry.rs +++ b/crates/doc/src/feature_geometry.rs @@ -152,6 +152,17 @@ macro_rules! geometry_walk { f(Pick(p)); } } + FeatureKind::SurfaceRuled { edges, direction, face, .. } => { + for e in edges { + f(Pos(e)); + } + if let Some(d) = direction { + f(Dir(d)); + } + if let Some(p) = face { + f(Pick(p)); + } + } FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => { for p in faces { f(Pos(p)); diff --git a/crates/doc/src/feature_refs.rs b/crates/doc/src/feature_refs.rs index 45a70eb2..ecaa2879 100644 --- a/crates/doc/src/feature_refs.rs +++ b/crates/doc/src/feature_refs.rs @@ -177,6 +177,7 @@ macro_rules! feature_walk { FeatureKind::SurfaceUntrim { face, .. } => f(B(& $($m)? face.body)), FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceExtend { body, .. } + | FeatureKind::SurfaceRuled { body, .. } | FeatureKind::Unstitch { body, .. } | FeatureKind::ReverseNormal { body, .. } | FeatureKind::MeshConvert { body, .. } => f(B(body)), diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index 2ca664bb..7aa70478 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -153,6 +153,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("reverse_normal", json!({"type": "reverse_normal", "body": "B"})), ("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})), ("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})), + ( + "surface_ruled", + json!({"type": "surface_ruled", "body": "B", "edges": [o], "distance": "ruled.distance", "mode": "angle", "angle": "ruled.angle", + "direction": [0, 0, 1], "face": [1, 0, 0]}), + ), ("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})), ("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})), ("import", json!({"type": "import", "file": "a.step", "step": ""})), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index cdf4971a..2fef4bc3 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -330,6 +330,11 @@ surface_extend inputs: Distance = extend.distance (Length) expressions: extend.distance +surface_ruled + inputs: + Distance = ruled.distance (Length) + Angle = ruled.angle (Angle) + expressions: ruled.distance, ruled.angle surface_trim inputs: Angle = trim.angle (Angle) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index 6a5b5eb0..98569900 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -155,6 +155,9 @@ surface_untrim face points: [0, 0, 0] surface_extend moves: edges[0][0], edges[0][1], edges[0][2] +surface_ruled + moves: direction[0], direction[2], edges[0][0], edges[0][1], edges[0][2], face[1], face[2] + face points: [1, 0, 0] surface_trim moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2] move diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index cf001b1c..39a698d5 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -172,6 +172,8 @@ surface_untrim remap_bodies: face.body surface_extend remap_bodies: body +surface_ruled + remap_bodies: body surface_trim remap_refs: plane.base.base.name remap_bodies: body, tool.body diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index d0fd79d4..a0091aa9 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -170,6 +170,9 @@ //! A `SurfaceUntrim` feature (Untrim: a `face` as `body` and `point`, optional `holes` //! points, `outer` and `in_place`) is also first saved in 21; 0.11.0 asks for an update //! instead of meeting a feature it can't read. +//! A `SurfaceRuled` feature (Ruled Surface: `body`, `edges` points, `distance`, optional +//! `mode`, `angle`, `direction`, `flip`, `corners` and `face`) is also first saved in 21; +//! 0.11.0 asks for an update instead of meeting a feature it can't read. use serde_json::Value; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index fd67063c..f35812dd 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -354,6 +354,12 @@ macro_rules! feature_walk { FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I), FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f), FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I), + FeatureKind::SurfaceRuled { distance, angle, .. } => { + f("Distance", distance, L, I); + if let Some(a) = angle { + f("Angle", a, A, I); + } + } FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } | FeatureKind::SurfaceUntrim { .. } => {} FeatureKind::SurfaceOffset { distance, tolerance, .. } => { f("Distance", distance, L, I); diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index e8fe717c..6eeee282 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -2,7 +2,7 @@ //! solids), and the planned surface toolset (`planned!`: registered, not built yet). use serde_json::Value; -use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind}; +use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind}; use super::CommandSpec; use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id}; @@ -50,10 +50,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("surface_offset") .params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"), - planned!("surface.ruled", "Ruled Surface", "762") + CommandSpec::new("surface.ruled", "Ruled Surface", ruled) .at("SURFACE", "CREATE") .icon("ruled") - .params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"), + .params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"), planned!("surface.boundary", "Boundary Surface", "763") .at("SURFACE", "CREATE") .icon("boundary") @@ -236,6 +236,54 @@ fn offset(s: &mut Session, p: &Value) -> Result { add_feature(s, p, kind) } +fn ruled(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.ruled"; + let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string(); + if s.model.state().body(&body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + let edges: Vec = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default(); + if edges.is_empty() || edges.len() > 10_000 { + return Err(bad(cmd, "`edges` must list points on a chain of the body's edges")); + } + let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?; + check_expr(s, &distance, Kind::Length, cmd, "distance")?; + let mode = match str_(p, "mode").map(str::to_ascii_lowercase).as_deref() { + None | Some("normal") => RuledMode::Normal, + Some("tangent") => RuledMode::Tangent, + Some("angle") => RuledMode::Angle, + Some("direction") => RuledMode::Direction, + Some(o) => return Err(bad(cmd, format!("unknown mode `{o}` (normal, tangent, angle or direction)"))), + }; + let angle = match mode { + RuledMode::Angle => { + let a = expr(p, "angle").ok_or_else(|| bad(cmd, "`angle` is required with mode angle"))?; + check_expr(s, &a, Kind::Angle, cmd, "angle")?; + Some(a) + } + _ => None, + }; + let direction = match (mode, p.get("direction").filter(|v| !v.is_null())) { + (RuledMode::Angle | RuledMode::Direction, Some(v)) => { + let d = vec3(v).filter(|d| d.len() > 1e-12).ok_or_else(|| bad(cmd, "`direction` must be a non-zero [x, y, z]"))?; + Some(d) + } + (RuledMode::Direction, None) => return Err(bad(cmd, "`direction` is required with mode direction")), + _ => None, + }; + let corners = match str_(p, "corners").map(str::to_ascii_lowercase).as_deref() { + None | Some("mitre") | Some("miter") => RuledCorners::Mitre, + Some("round") => RuledCorners::Round, + Some(o) => return Err(bad(cmd, format!("unknown corners `{o}` (mitre or round)"))), + }; + let face = match p.get("face").filter(|v| !v.is_null()) { + Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`face` must be [x, y, z] on a face"))?), + None => None, + }; + let kind = FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip: bool_(p, "flip").unwrap_or(false), corners, face }; + add_feature(s, p, kind) +} + fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result> { let bodies = string_list(p, "bodies"); if bodies.is_empty() { @@ -666,6 +714,59 @@ mod tests { assert!(s.execute("surface.offset", &json!({"distance": 1})).is_err()); } + /// Ruled Surface round a box top (10 × 10), 2 wide: across the top, mitred, a 14 × 14 frame + /// less the top (96); up the top's normal, 80; at 45° with round corners, 80 + π·√2·2; along + /// a direction, 2 × 10 off one edge. The strips' faces are named after the edges they stand + /// on. Zero, a missing direction, an unknown mode and a non-finite distance are refused, the + /// design left as it was; undo, redo and reopening. + #[test] + fn ruled_surface_round_a_box_top() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})); + let rim = json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]]); + let top = json!([5, 5, 10]); + run(&mut s, "surface.ruled", json!({"body": "Box", "edges": rim, "distance": "2 mm", "mode": "tangent", "face": top, "body_name": "Flange"})); + assert!((area(&s, "Flange") - 96.0).abs() < 1e-9, "{}", area(&s, "Flange")); + assert!(s.world_state().body("Flange").unwrap().body.is_surface() && s.world_state().body("Box").is_some()); + run(&mut s, "surface.ruled", json!({"body": "Box", "edges": rim, "distance": 2, "face": top, "body_name": "Wall"})); + assert!((area(&s, "Wall") - 80.0).abs() < 1e-9); + run( + &mut s, + "surface.ruled", + json!({"body": "Box", "edges": rim, "distance": 2, "mode": "angle", "angle": "45 deg", "corners": "round", "face": top, "body_name": "Draft"}), + ); + let want = 80.0 + std::f64::consts::PI * 2f64.sqrt() * 2.0; + assert!((area(&s, "Draft") - want).abs() < want * 2e-3, "{} vs {want}", area(&s, "Draft")); // (measured on meshes) + run( + &mut s, + "surface.ruled", + json!({"body": "Box", "edges": [[5, 0, 10]], "distance": 2, "mode": "direction", "direction": [0, -1, 0], "body_name": "Lip"}), + ); + let bb = solidcraft_kernel::measure(&s.world_state().body("Lip").unwrap().body).unwrap().bbox; + assert!((area(&s, "Lip") - 20.0).abs() < 1e-9 && (bb.min.y + 2.0).abs() < 1e-9); + let st = s.world_state(); + let names = solidcraft_doc::naming::face_names(st.body("Flange").unwrap()); + assert!(names.len() == 4 && names.iter().all(|n| !n.is_empty()), "{names:?}"); + let before = s.doc.features.len(); + for bad in [ + json!({"body": "Box", "edges": rim, "distance": 0}), + json!({"body": "Box", "edges": rim, "distance": "1e400 mm"}), + json!({"body": "Box", "edges": rim, "distance": 2, "mode": "direction"}), + json!({"body": "Box", "edges": rim, "distance": 2, "mode": "sideways"}), + json!({"body": "Box", "edges": rim, "distance": 2, "mode": "angle"}), + json!({"body": "Box", "distance": 2}), + json!({"body": "Nope", "edges": rim, "distance": 2}), + ] { + assert!(s.execute("surface.ruled", &bad).is_err(), "{bad}"); + } + assert_eq!(s.doc.features.len(), before); + run(&mut s, "edit.undo", json!({})); + assert!(s.world_state().body("Lip").is_none()); + run(&mut s, "edit.redo", json!({})); + let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((area(&cold, "Draft") - area(&s, "Draft")).abs() < 1e-9); + } + /// A 40 × 20 × 2 plate with three holes of radius 2 through it at x = 10, 20, 30 on y = 10. fn plate_with_three_holes(s: &mut Session) { run(s, "solid.box", json!({"length": 40, "width": 20, "height": 2, "body_name": "Plate"})); @@ -775,7 +876,7 @@ mod tests { /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { - let planned = ["surface.ruled", "surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"]; + let planned = ["surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"]; let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); let before = s.doc.features.len(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b1e2bdc1..7cb138fa 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -94,6 +94,7 @@ mod step_in; mod step_out; mod surface_kind; mod surface_offset; +mod surface_ruled; mod surface_untrim; mod surfaces; mod sweep_path; @@ -138,6 +139,7 @@ pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_imp pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_kind::{FaceSurface, face_surface}; pub use surface_offset::offset_surface; +pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface}; pub use surface_untrim::{UntrimHoles, untrim}; pub use surfaces::{ copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, diff --git a/crates/kernel/src/surface_ruled.rs b/crates/kernel/src/surface_ruled.rs new file mode 100644 index 00000000..c19a1ae9 --- /dev/null +++ b/crates/kernel/src/surface_ruled.rs @@ -0,0 +1,880 @@ +//! Ruled surfaces (Surface › Ruled): a strip hanging off a chain of a body's edges, a given +//! width out from them, as a new surface body. Each edge carries one strip, ruled by straight +//! lines from the edge in a direction set by the face beside it: along its normal, continuing +//! it, at an angle from a reference direction (a draft), or along a fixed vector. +//! +//! A strip is exact where the rulings move the edge by an affine map: a straight edge with a +//! constant ruling direction gives a plane, an arc whose rulings keep their angle to its axis +//! gives a plane (a ring), a cylinder or a cone. Any other strip is a ruled B-spline (degree 1 +//! across) fitted within [`FIT_TOLERANCE`]. Strips share their side edges where their rulings +//! agree; at a sharp corner of the chain they are mitred (each extended along its far edge +//! until the two meet, or cut back where they overlap) or rounded (a planar or conical sector +//! turning one ruling into the other; an inside corner is always cut back). The strips stand +//! on the body's own edges, so they come out stitched together. + +use std::collections::HashMap; + +use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter}; +use solidcraft_geom::Vec3; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3, v3}; +use crate::record::{Rebuild, rebuilt_body}; +use crate::surfaces::{map_control_points, nearest_face, picked_chain, surface_body}; +use crate::{KernelError, Result, guard}; + +/// How far a fitted strip may stray from the true ruled surface (mm). +const FIT_TOLERANCE: f64 = 1e-3; +/// Samples along an edge when classifying its strip, and the most a fitted strip is refined to. +const SAMPLES: usize = 16; +const MAX_FIT_SAMPLES: usize = 513; +/// Rulings closer than this (as unit vectors) agree: their strips share a side edge. +const SAME_RULING: f64 = 1e-7; + +/// The direction a ruled strip leaves its edge. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RuledMode { + /// Along the normal of the face beside the edge. + Normal, + /// Continuing the face beside the edge, square to the edge. + Tangent, + /// `angle` (radians) from `reference` (default: the face's normal), turned away from the + /// face about the edge: 0 is the reference, 90° square to it. + Angle { angle: f64, reference: Option }, + /// Along a fixed vector. + Direction(Vec3), +} + +/// How strips meet at a sharp corner of the chain. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RuledCorners { + /// Extended (or cut back) until they meet. + #[default] + Mitre, + /// Joined by a sector turning one ruling into the other (inside corners are cut back). + Round, +} + +/// A ruled surface's settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ruled { + /// How far the strip reaches along its rulings (mm, positive). + pub distance: f64, + pub mode: RuledMode, + /// The strip on the other side: every ruling reversed. + pub flip: bool, + pub corners: RuledCorners, + /// A point on the face the directions are taken from, for the edges it borders; elsewhere + /// the face beside the most picked edges (the first in face order on a tie). + pub face: Option, +} + +/// A ruled surface hanging off the chain of `body`'s edges picked by the points `at` (open or +/// closed), as one surface body. The history is over `[body]`: each strip is made in place of +/// the edge it stands on, a corner face in place of the two edges meeting there. +pub fn ruled_surface(body: &Body, at: &[Vec3], r: &Ruled) -> Result { + body.require_brep("ruled surface")?; + let d = r.distance; + if !(d.is_finite() && d > 0.0 && d < 1e6) { + return Err(KernelError::Invalid("the ruled surface distance must be positive and finite".into())); + } + if at.is_empty() || at.len() > 10_000 { + return Err(KernelError::Invalid("pick the edges of a chain".into())); + } + let unit = + |v: Vec3, what: &str| v.normalized().filter(|u| u.is_finite()).ok_or_else(|| KernelError::Invalid(format!("the {what} has no length"))); + let mode = match r.mode { + RuledMode::Angle { angle, reference } => { + if !(angle.is_finite() && angle.abs() <= std::f64::consts::TAU) { + return Err(KernelError::Invalid("the ruled surface angle must be finite, a full turn at most".into())); + } + RuledMode::Angle { angle, reference: reference.map(|v| unit(v, "reference direction")).transpose()? } + } + RuledMode::Direction(v) => RuledMode::Direction(unit(v, "direction")?), + m => m, + }; + let picked_face = r.face.map(|p| nearest_face(body, p)).transpose()?; + let eps = 1e-9 * (1.0 + body.size() + d); + guard("ruled surface", || { + let (chain, closed) = picked_chain(body, at)?; + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + // The faces beside each chain edge (face index, the face's own use of the edge). + let mut beside: HashMap> = chain.iter().map(|e| (e.id(), Vec::new())).collect(); + for (i, f) in faces.iter().enumerate() { + for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) { + if let Some(list) = beside.get_mut(&e.id()) { + list.push((i, e.clone())); + } + } + } + let mut count: HashMap = HashMap::new(); + for list in beside.values() { + for (i, _) in list { + *count.entry(*i).or_default() += 1; + } + } + let empty = Vec::new(); + let around = |e: &mt::Edge| beside.get(&e.id()).unwrap_or(&empty); + // The face each edge takes its directions from: the picked face where it borders the + // edge, carried on along the chain to the faces meeting it smoothly (a cylinder's + // halves); with no picked face beside the chain, the face beside the most edges. + let n = chain.len(); + let mut chosen: Vec> = chain.iter().map(|e| around(e).iter().position(|(i, _)| Some(*i) == picked_face)).collect(); + if chosen.iter().all(Option::is_none) { + chosen = chain + .iter() + .map(|e| { + let list = around(e); + (0..list.len()).max_by(|a, b| { + let key = |k: &usize| list.get(*k).map(|(i, _)| (count.get(i).copied().unwrap_or(0), std::cmp::Reverse(*i))); + key(a).cmp(&key(b)) + }) + }) + .collect(); + } + // Carried on from a chosen neighbour across the vertex they share. + let normal = |face: usize, p: mt::Point3| faces.get(face).and_then(|f| normal_at(f, from_p3(p))); + for _ in 0..n { + let mut changed = false; + for j in 0..n { + if chosen.get(j).copied().flatten().is_some() { + continue; + } + let Some(e) = chain.get(j) else { continue }; + let neighbours = [ + (j.checked_sub(1).or(closed.then(|| n - 1)), e.front().point()), + ((j + 1 < n).then_some(j + 1).or(closed.then_some(0)), e.back().point()), + ]; + let pick = neighbours.iter().find_map(|(k, p)| { + let k = (*k)?; + let (face, _) = around(chain.get(k)?).get(chosen.get(k).copied().flatten()?)?; + let want = normal(*face, *p)?; + let list = around(e); + (0..list.len()).max_by(|a, b| { + let dot = |k: &usize| list.get(*k).and_then(|(i, _)| normal(*i, *p)).map_or(f64::NEG_INFINITY, |nn| nn.dot(want)); + dot(a).total_cmp(&dot(b)) + }) + }); + if let (Some(k), Some(slot)) = (pick, chosen.get_mut(j)) { + *slot = Some(k); + changed = true; + } + } + if !changed { + break; + } + } + let field = Field { mode, flip: r.flip, distance: d }; + let mut strips = Vec::with_capacity(n); + for (e, k) in chain.iter().zip(&chosen) { + let (i, own) = k.and_then(|k| around(e).get(k)).ok_or_else(|| KernelError::Invalid("a picked edge has no face beside it".into()))?; + let face = faces.get(*i).ok_or_else(|| KernelError::Failed("ruled surface: a face".into()))?; + // The face lies left of its own boundary (seen along its normal); the strip's + // tangent direction points away from it. + let face_left = own.orientation() == e.orientation(); + strips.push(Strip::new(e.clone(), face, face_left, &field, eps)?); + } + // Joints at the chain's vertices: joint j is where strip j starts (and strip j - 1 + // ends); an open chain has one more, at its far end. + let joints = (0..n + usize::from(!closed)) + .map(|j| { + let incoming = if j > 0 { + strips.get(j - 1) + } else if closed { + strips.last() + } else { + None + }; + let outgoing = strips.get(j).filter(|_| closed || j < n); + Joint::new(incoming, outgoing, &mode, r.corners, eps) + }) + .collect::>>()?; + let mut rec = Rebuild::default(); + let mut out = Vec::new(); + for (j, s) in strips.iter().enumerate() { + let start = joints.get(j).ok_or_else(|| KernelError::Failed("ruled surface: a joint".into()))?; + let end = joints.get(if closed { (j + 1) % n } else { j + 1 }).ok_or_else(|| KernelError::Failed("ruled surface: a joint".into()))?; + let (up_start, top_start) = start.side(Side::Out, s); + let (up_end, top_end) = end.side(Side::In, s); + let top = if s.line { + mt::Edge::new_unchecked(&top_start, &top_end, mt::Curve::Line(mt::Line(top_start.point(), top_end.point()))) + } else { + mt::Edge::new_unchecked(&top_start, &top_end, s.top.clone()) + }; + if s.line && (from_p3(top_end.point()) - from_p3(top_start.point())).dot(s.start.tangent) <= eps { + return Err(KernelError::Invalid("the corners cut a strip away entirely: try a shorter distance".into())); + } + let mut wire = vec![s.base.clone()]; + wire.extend(up_end.iter().cloned()); + wire.push(top.inverse()); + wire.extend(up_start.iter().rev().map(mt::Edge::inverse)); + let face = mt::Face::new_unchecked(vec![wire.into()], s.surface.clone()); + rec.blend(&face, [s.base.id()]); + out.push(face); + out.extend(end.corner_faces().into_iter().cloned()); + } + // Corner faces are made in place of the two edges meeting at the corner. + for (j, jt) in joints.iter().enumerate() { + let near: Vec = [ + if j > 0 { + strips.get(j - 1) + } else if closed { + strips.last() + } else { + None + }, + strips.get(j), + ] + .into_iter() + .flatten() + .map(|s| s.base.id()) + .collect(); + for f in jt.corner_faces() { + rec.blend(f, near.iter().copied()); + } + } + Ok(rebuilt_body(surface_body(out), &[&body.solid], &rec)) + }) +} + +/// How the rulings are set at a point of an edge. +struct Field { + mode: RuledMode, + flip: bool, + distance: f64, +} + +impl Field { + /// The unit ruling at a point where the edge runs along `t` (unit, chain direction), the + /// face's normal is `n` and `out` (unit) points away from the face, square to the edge. + fn ruling(&self, t: Vec3, n: Vec3, out: Vec3) -> Result { + let dir = match self.mode { + RuledMode::Normal => n, + RuledMode::Tangent => out, + RuledMode::Angle { angle, reference } => { + let r = reference.unwrap_or(n); + let r = (r - t * r.dot(t)).normalized().ok_or_else(|| KernelError::Invalid("the reference direction runs along an edge".into()))?; + // Square to the reference and the edge, turned away from the face. + let mut w = t.cross(r); + if w.dot(out + n) < 0.0 { + w = -w; + } + r * angle.cos() + w * angle.sin() + } + RuledMode::Direction(v) => v, + }; + let dir = if self.flip { -dir } else { dir }; + if dir.cross(t).len() < 1e-6 { + return Err(KernelError::Invalid("the strip would run along an edge: pick another direction".into())); + } + Ok(dir) + } +} + +/// One end of a strip, in the chain's direction. +#[derive(Clone, Copy)] +struct End { + /// The edge's end point, the unit ruling there and where the ruling ends. + at: Vec3, + ruling: Vec3, + top: Vec3, + /// The unit tangent of the strip's far edge there, and the face's normal. + tangent: Vec3, + normal: Vec3, +} + +/// The strip on one edge. +struct Strip { + /// The body's edge, turned to run along the chain. + base: mt::Edge, + surface: mt::Surface, + /// The far edge's curve (from the start's ruling end to the end's). + top: mt::Curve, + /// A plane through a straight edge: its far edge is the line between its corners. + line: bool, + /// The strip lies in a plane. + planar: bool, + start: End, + end: End, +} + +impl Strip { + fn new(base: mt::Edge, face: &mt::Face, face_left: bool, field: &Field, eps: f64) -> Result { + let c = base.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let d = field.distance; + // The point, unit tangent, face normal and ruling at a parameter of the edge. + let at = |t: f64| -> Result<(Vec3, Vec3, Vec3, Vec3)> { + let p = from_p3(c.subs(t)); + let tan = from_p3(mt::Point3::new(0.0, 0.0, 0.0) + c.der(t)) + .normalized() + .ok_or_else(|| KernelError::Invalid("a picked edge has a point with no direction".into()))?; + let n = normal_at(face, p) + .ok_or_else(|| KernelError::Invalid("the face beside an edge has a point with no normal there: not supported yet".into()))?; + let out = if face_left { tan.cross(n) } else { n.cross(tan) }; + let out = out.normalized().ok_or_else(|| KernelError::Invalid("the face beside an edge runs along it".into()))?; + Ok((p, tan, n, field.ruling(tan, n, out)?)) + }; + let param = |k: usize, m: usize| t0 + (t1 - t0) * k as f64 / (m - 1) as f64; + let samples: Vec<(Vec3, Vec3, Vec3, Vec3)> = (0..SAMPLES).map(|k| at(param(k, SAMPLES))).collect::>()?; + let (Some(first), Some(last)) = (samples.first().copied(), samples.last().copied()) else { + return Err(KernelError::Failed("ruled surface: samples".into())); + }; + let tops: Vec = samples.iter().map(|(p, _, _, r)| *p + *r * d).collect(); + // A plane through the edge and its rulings, when everything lies in one. + let plane_normal = first.1.cross(first.3).normalized(); + let planar = + plane_normal.is_some_and(|nn| samples.iter().map(|s| s.0).chain(tops.iter().copied()).all(|q| (q - first.0).dot(nn).abs() <= eps)); + let exact = affine(&samples, d, eps).and_then(|m| map_control_points(&c, |p| m.apply(p))); + let line = planar && matches!(c, mt::Curve::Line(_)) && exact.is_some(); + let (surface, top) = match (planar, exact) { + (true, Some(top)) => (plane(first.0, first.1, first.3), top), + (false, Some(top)) => { + let s = mt::BSplineSurface::homotopy(c.clone().lift_up(), top.clone().lift_up()); + (mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)), top) + } + (planar, None) => { + let (s, top) = fitted(&at, ¶m, d)?; + (if planar { plane(first.0, first.1, first.3) } else { s }, top) + } + }; + let (s0, s1) = top.range_tuple(); + let tangent = |s: f64| from_p3(mt::Point3::new(0.0, 0.0, 0.0) + top.der(s)).normalized().unwrap_or(first.1); + let end = |(p, _, n, r): (Vec3, Vec3, Vec3, Vec3), tan: Vec3| End { at: p, ruling: r, top: p + r * d, tangent: tan, normal: n }; + Ok(Strip { start: end(first, tangent(s0)), end: end(last, tangent(s1)), base, surface, top, line, planar }) + } +} + +/// The unit normal of `face` (as it faces) at its point nearest `p`. +fn normal_at(face: &mt::Face, p: Vec3) -> Option { + let surface = face.oriented_surface(); + let (u, v) = surface.search_nearest_parameter(p3(p), None, 100)?; + from_p3(mt::Point3::new(0.0, 0.0, 0.0) + surface.normal(u, v)).normalized() +} + +/// The plane through `o` spanned by the edge direction `t` and the ruling `r`, its normal t × r +/// (the strip's own orientation). +fn plane(o: Vec3, t: Vec3, r: Vec3) -> mt::Surface { + mt::Surface::Plane(mt::Plane::new(p3(o), p3(o + t), p3(o + r))) +} + +/// An affine map taking each point of an edge to the end of its ruling. +enum Affine { + Translate(Vec3), + /// Points scaled by `scale` about the axis through `c` along `a` (in its square plane), + /// then moved by `shift`. + Radial { + c: Vec3, + a: Vec3, + scale: f64, + shift: Vec3, + }, +} + +impl Affine { + fn apply(&self, p: Vec3) -> Vec3 { + match *self { + Affine::Translate(v) => p + v, + Affine::Radial { c, a, scale, shift } => { + let q = p - c; + let along = q.dot(a); + c + (q - a * along) * scale + a * along + shift + } + } + } +} + +/// The affine map the rulings (scaled by `d`) make of the sampled edge, when there is one: a +/// constant ruling translates; on a circular arc, rulings keeping their radial and axial parts +/// (and none along the arc) scale about its axis and shift along it. +fn affine(samples: &[(Vec3, Vec3, Vec3, Vec3)], d: f64, eps: f64) -> Option { + let first = samples.first()?; + if samples.iter().all(|s| (s.3 - first.3).len() * d <= eps) { + return Some(Affine::Translate(first.3 * d)); + } + let k = samples.len(); + let (a, b, c) = (samples.get(k / 6)?.0, samples.get(k / 2)?.0, samples.get(k - 1 - k / 6)?.0); + let (u, v) = (b - a, c - a); + let w = u.cross(v); + let ww = w.dot(w); + if !(ww > 1e-24) { + return None; + } + let centre = a + (v.cross(w) * u.dot(u) + w.cross(u) * v.dot(v)) / (2.0 * ww); + let axis = w.normalized()?; + let radius = (a - centre).len(); + let (mut radial, mut along) = (None, None); + for (p, t, _, r) in samples { + let q = *p - centre; + if q.dot(axis).abs() > eps || (q.len() - radius).abs() > eps { + return None; + } + let rho = q.normalized()?; + let (x, y) = (r.dot(rho) * d, r.dot(axis) * d); + if (r.dot(*t) * d).abs() > eps || radial.is_some_and(|x0: f64| (x - x0).abs() > eps) || along.is_some_and(|y0: f64| (y - y0).abs() > eps) { + return None; + } + radial.get_or_insert(x); + along.get_or_insert(y); + } + Some(Affine::Radial { c: centre, a: axis, scale: 1.0 + radial? / radius, shift: axis * along? }) +} + +/// A ruled B-spline fitted to the strip (degree 1 across: cubics through matching samples of +/// the edge and of its rulings' ends), refined until both are within [`FIT_TOLERANCE`]; and its +/// far edge. +fn fitted(at: &dyn Fn(f64) -> Result<(Vec3, Vec3, Vec3, Vec3)>, param: &dyn Fn(usize, usize) -> f64, d: f64) -> Result<(mt::Surface, mt::Curve)> { + let mut m = SAMPLES + 1; + loop { + let pts: Vec<(Vec3, Vec3, Vec3, Vec3)> = (0..m).map(|k| at(param(k, m))).collect::>()?; + let near: Vec = pts.iter().map(|s| s.0).collect(); + let far: Vec = pts.iter().map(|s| s.0 + s.3 * d).collect(); + let fail = || KernelError::Failed("ruled surface: fitting a strip".into()); + let (base, top) = (crate::build::interpolate_cubic(&near).ok_or_else(fail)?, crate::build::interpolate_cubic(&far).ok_or_else(fail)?); + // Checked between the samples, where a fit strays most. + let mut worst = 0.0f64; + for k in 0..m - 1 { + let s = (k as f64 + 0.5) / (m - 1) as f64; + let (p, _, _, r) = at(param(2 * k + 1, 2 * m - 1))?; + worst = worst.max(from_p3(base.subs(s)).dist(p)).max(from_p3(top.subs(s)).dist(p + r * d)); + } + if worst <= FIT_TOLERANCE { + let surface = mt::Surface::BSplineSurface(mt::BSplineSurface::homotopy(base, top.clone())); + return Ok((surface, mt::Curve::BSplineCurve(top))); + } + if m >= MAX_FIT_SAMPLES { + return Err(KernelError::Invalid(format!("the strip can't be fitted within {FIT_TOLERANCE} mm (off by up to {worst:.4} mm)"))); + } + m = (2 * m - 1).min(MAX_FIT_SAMPLES); + } +} + +/// Which strip of a joint: the one ending there or the one starting there. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Side { + In, + Out, +} + +/// How the strips meet at a vertex of the chain (or end at a free end of it). +enum Joint { + /// One side edge from the vertex up the ruling: the strips' rulings agree, or a free end. + Shared(mt::Edge), + /// Mitred: the edge from the vertex to where the strips' far edges meet (`m`); a curved + /// strip keeps its own side edge and gets a triangle (`incoming`, `outgoing`) up to it. + Mitre { edge: mt::Edge, m: mt::Vertex, incoming: Option<(mt::Edge, mt::Face)>, outgoing: Option<(mt::Edge, mt::Face)> }, + /// Rounded: each strip's own side edge, and the sector turning one into the other (in + /// pieces of less than a half turn). + Round { incoming: mt::Edge, outgoing: mt::Edge, sector: Vec }, +} + +impl Joint { + fn new(a: Option<&Strip>, b: Option<&Strip>, mode: &RuledMode, corners: RuledCorners, eps: f64) -> Result { + let side = |s: &Strip, e: &End, from_start: bool| { + let v = if from_start { s.base.front().clone() } else { s.base.back().clone() }; + let top = mt::Vertex::new(p3(e.top)); + mt::Edge::new_unchecked(&v, &top, mt::Curve::Line(mt::Line(v.point(), top.point()))) + }; + let (a, b) = match (a, b) { + (Some(a), Some(b)) => (a, b), + (Some(a), None) => return Ok(Joint::Shared(side(a, &a.end, false))), + (None, Some(b)) => return Ok(Joint::Shared(side(b, &b.start, true))), + (None, None) => return Err(KernelError::Failed("ruled surface: an empty joint".into())), + }; + let (ea, eb) = (&a.end, &b.start); + let vertex = a.base.back().clone(); + if (ea.ruling - eb.ruling).len() <= SAME_RULING { + let top = mt::Vertex::new(p3((ea.top + eb.top) * 0.5)); + return Ok(Joint::Shared(mt::Edge::new_unchecked(&vertex, &top, mt::Curve::Line(mt::Line(vertex.point(), top.point()))))); + } + // Where the far edges, carried on straight, meet: λ along the incoming one past its + // end, μ along the outgoing one past its start. + let meet = || -> Option<(Vec3, f64, f64)> { + let (p, q, u, v) = (ea.top, eb.top, ea.tangent, eb.tangent); + let w = p - q; + let (uu, uv, vv, uw, vw) = (u.dot(u), u.dot(v), v.dot(v), u.dot(w), v.dot(w)); + let den = uu * vv - uv * uv; + if den < 1e-12 { + return None; + } + let (lam, mu) = ((uv * vw - vv * uw) / den, (uu * vw - uv * uw) / den); + let (x, y) = (p + u * lam, q + v * mu); + (x.dist(y) <= eps * 100.0 && lam.abs() < 1e3 * (1.0 + ea.at.dist(ea.top))).then_some(((x + y) * 0.5, lam, mu)) + }; + // An inside corner (the strips overlap) is cut back where the far edges meet; that needs + // straight far edges. + let inside = eb.ruling.dot(ea.tangent) - ea.ruling.dot(eb.tangent) < -1e-9; + let corner_at = vertex.point(); + let refuse = |what: &str| KernelError::Invalid(format!("{what} at the corner at {:?}", from_p3(corner_at))); + if corners == RuledCorners::Mitre || inside { + let (m, lam, mu) = meet().ok_or_else(|| refuse("the strips' far edges don't meet: try round corners,"))?; + let convex = lam > 0.0 && mu < 0.0; + let concave = lam < 0.0 && mu > 0.0; + if !(convex || concave) { + return Err(refuse("the strips cross")); + } + if concave && !(a.line && b.line) { + return Err(refuse("not supported yet: cutting back a curved strip")); + } + let mv = mt::Vertex::new(p3(m)); + let edge = mt::Edge::new_unchecked(&vertex, &mv, mt::Curve::Line(mt::Line(vertex.point(), mv.point()))); + // A strip that isn't a plane keeps its side edge and gets a triangle up to the + // mitre, in its tangent plane there. + let triangle = |s: &Strip, e: &End, from_start: bool| -> Option<(mt::Edge, mt::Face)> { + if s.planar { + return None; + } + let own = side(s, e, from_start); + let top = own.back().clone(); + let rim = mt::Edge::new_unchecked(&mv, &top, mt::Curve::Line(mt::Line(mv.point(), top.point()))); + let (wire, surface): (Vec, _) = if from_start { + (vec![own.clone(), rim.inverse(), edge.inverse()], plane_through(e.at, e.top, m)) + } else { + (vec![edge.clone(), rim, own.inverse()], plane_through(e.at, m, e.top)) + }; + Some((own, mt::Face::new_unchecked(vec![wire.into()], surface))) + }; + let incoming = triangle(a, ea, false); + let outgoing = triangle(b, eb, true); + return Ok(Joint::Mitre { edge, m: mv, incoming, outgoing }); + } + // Rounded: the incoming ruling turned into the outgoing one about an axis both keep + // the same angle to (the reference direction, or the faces' normal: a cone), else + // about the axis square to both (a flat sector). + let mut axes: Vec = Vec::new(); + if let RuledMode::Angle { reference: Some(r), .. } = mode { + axes.push(*r); + } + if let Some(n) = (ea.normal + eb.normal).normalized() { + axes.push(n); + } + let flat = |x: Vec3, ax: Vec3| x - ax * x.dot(ax); + let axis = axes + .into_iter() + .find(|ax| { + (ea.ruling.dot(*ax) - eb.ruling.dot(*ax)).abs() <= 1e-9 && flat(ea.ruling, *ax).len() > 1e-6 && flat(eb.ruling, *ax).len() > 1e-6 + }) + .or_else(|| ea.ruling.cross(eb.ruling).normalized()) + .ok_or_else(|| refuse("the strips turn back on each other"))?; + let (pa, pb) = (flat(ea.ruling, axis), flat(eb.ruling, axis)); + let turn = pa.cross(pb).dot(axis).atan2(pa.dot(pb)); + let (axis, turn) = if turn < 0.0 { (-axis, -turn) } else { (axis, turn) }; + if !(turn > 1e-9 && turn < std::f64::consts::PI - 1e-6) { + return Err(refuse("the strips turn back on each other")); + } + let incoming = side(a, ea, false); + // Swept about the vertex (on the axis, so the sector closes there in a point). + let mut sector: Vec = mt::builder::cone(&vec![incoming.clone()].into(), v3(axis), mt::Rad(turn)).into_iter().collect(); + let edges: Vec = + sector.iter().flat_map(|f| f.boundaries().into_iter().flat_map(|w| w.edge_iter().cloned().collect::>())).collect(); + // The sector runs down the incoming side (the strip runs up it). + if edges.iter().any(|e| e.id() == incoming.id() && e.front().id() == vertex.id()) { + for f in &mut sector { + f.invert(); + } + } + // Its other side: the edge from the vertex that only one piece of it has. + let uses = |id: mt::EdgeID| edges.iter().filter(|e| e.id() == id).count(); + let outgoing = edges + .iter() + .find(|e| e.id() != incoming.id() && uses(e.id()) == 1 && (e.front().id() == vertex.id() || e.back().id() == vertex.id())) + .map(|e| if e.front().id() == vertex.id() { e.clone() } else { e.inverse() }) + .ok_or_else(|| KernelError::Failed("ruled surface: a round corner's side".into()))?; + Ok(Joint::Round { incoming, outgoing, sector }) + } + + /// The edges a strip runs up from the joint's vertex (in order), and the vertex its far edge + /// starts or ends at. + fn side(&self, which: Side, s: &Strip) -> (Vec, mt::Vertex) { + match self { + Joint::Shared(e) => (vec![e.clone()], e.back().clone()), + Joint::Round { incoming, outgoing, .. } => { + let e = if which == Side::In { incoming } else { outgoing }; + (vec![e.clone()], e.back().clone()) + } + Joint::Mitre { edge, m, incoming, outgoing } => { + let own = if which == Side::In { incoming } else { outgoing }; + if let Some((e, _)) = own { + return (vec![e.clone()], e.back().clone()); + } + if s.line { + return (vec![edge.clone()], m.clone()); + } + // A plane under a curved far edge: on along the mitre's far edge to it. + let e = if which == Side::In { &s.end } else { &s.start }; + let top = mt::Vertex::new(p3(e.top)); + let rim = mt::Edge::new_unchecked(m, &top, mt::Curve::Line(mt::Line(m.point(), top.point()))); + (vec![edge.clone(), rim], top) + } + } + } + + /// The joint's own faces: a round corner's sector, a mitre's triangles. + fn corner_faces(&self) -> Vec<&mt::Face> { + match self { + Joint::Shared(_) => Vec::new(), + Joint::Round { sector, .. } => sector.iter().collect(), + Joint::Mitre { incoming, outgoing, .. } => incoming.iter().chain(outgoing).map(|(_, f)| f).collect(), + } + } +} + +/// The plane through three points, its normal (b − a) × (c − a). +fn plane_through(a: Vec3, b: Vec3, c: Vec3) -> mt::Surface { + mt::Surface::Plane(mt::Plane::new(p3(a), p3(b), p3(c))) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::f64::consts::{FRAC_1_SQRT_2, PI}; + + use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + + use super::*; + use crate::history::FaceOrigin; + use crate::{FaceSurface, box_solid, cylinder, face_surface, measure}; + + fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) + } + fn ruled(mode: RuledMode, distance: f64, face: Vec3) -> Ruled { + Ruled { distance, mode, flip: false, corners: RuledCorners::Mitre, face: Some(face) } + } + fn area(b: &Body) -> f64 { + measure(b).unwrap().area + } + /// How many faces use each edge of a body. + fn uses(b: &Body) -> HashMap { + let mut m = HashMap::new(); + for e in b.solid.face_iter().flat_map(|f| f.boundaries().into_iter().flat_map(|w| w.edge_iter().map(|e| e.id()).collect::>())) { + *m.entry(e).or_default() += 1; + } + m + } + fn shared(b: &Body) -> usize { + uses(b).values().filter(|n| **n == 2).count() + } + + /// A straight edge (a box's top front edge, 10 long) with a strip 4 wide in every mode: a + /// 10 × 4 plane each time, leaving the edge the way the mode says. + #[test] + fn a_straight_edge_in_every_mode() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let (edge, top, front) = ([Vec3::new(5.0, 0.0, 10.0)], Vec3::new(5.0, 5.0, 10.0), Vec3::new(5.0, 0.0, 5.0)); + let (s30, c30) = (30f64.to_radians().sin(), 30f64.to_radians().cos()); + let (s10, c10) = (10f64.to_radians().sin(), 10f64.to_radians().cos()); + let cases: [(Ruled, Vec3, Vec3); 7] = [ + // Up the top's normal. + (ruled(RuledMode::Normal, 4.0, top), Vec3::new(0.0, 0.0, 10.0), Vec3::new(10.0, 0.0, 14.0)), + // Out across the top's edge, continuing it. + (ruled(RuledMode::Tangent, 4.0, top), Vec3::new(0.0, -4.0, 10.0), Vec3::new(10.0, 0.0, 10.0)), + // 30° from the top's normal, leaning away from it. + ( + ruled(RuledMode::Angle { angle: 30f64.to_radians(), reference: None }, 4.0, top), + Vec3::new(0.0, -4.0 * s30, 10.0), + Vec3::new(10.0, 0.0, 10.0 + 4.0 * c30), + ), + // A 10° draft from the pull direction (+Z) off the front face's top edge: out from + // the part. + ( + ruled(RuledMode::Angle { angle: 10f64.to_radians(), reference: Some(Vec3::Z) }, 4.0, front), + Vec3::new(0.0, -4.0 * s10, 10.0), + Vec3::new(10.0, 0.0, 10.0 + 4.0 * c10), + ), + // Along a fixed vector (not a unit one). + ( + ruled(RuledMode::Direction(Vec3::new(0.0, -3.0, 3.0)), 4.0, top), + Vec3::new(0.0, -4.0 * FRAC_1_SQRT_2, 10.0), + Vec3::new(10.0, 0.0, 10.0 + 4.0 * FRAC_1_SQRT_2), + ), + // The front face's normal (-Y), and flipped: down the top's normal. + (ruled(RuledMode::Normal, 4.0, front), Vec3::new(0.0, -4.0, 10.0), Vec3::new(10.0, 0.0, 10.0)), + (Ruled { flip: true, ..ruled(RuledMode::Normal, 4.0, top) }, Vec3::new(0.0, 0.0, 6.0), Vec3::new(10.0, 0.0, 10.0)), + ]; + for (r, lo, hi) in cases { + let s = ruled_surface(&b, &edge, &r).unwrap(); + let m = measure(&s).unwrap(); + assert!(s.is_surface() && s.face_count() == 1, "{r:?}"); + assert!(rel(m.area, 40.0) < 1e-9, "{r:?}: {}", m.area); + assert!(m.bbox.min.dist(lo) < 1e-9 && m.bbox.max.dist(hi) < 1e-9, "{r:?}: {:?}", m.bbox); + assert!(matches!(face_surface(&s, 0), Some(FaceSurface::Plane { .. })), "{r:?}"); + } + } + + /// A circle (a cylinder's top rim, radius 10, picked all round) with a strip 4 wide in every + /// mode, from the top face: a cylinder 2π·10·4 up its normal, a ring π(14² − 10²) across + /// it, a cone π·4·(2·10 + 4 sin 30°) at 30°, and the cylinder again along +Z. From the side + /// face instead, its normal makes the ring and its tangent the cylinder. + #[test] + fn a_circle_in_every_mode() { + let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap(); + let rim = [Vec3::new(10.0, 0.0, 20.0), Vec3::new(0.0, 10.0, 20.0), Vec3::new(-10.0, 0.0, 20.0), Vec3::new(0.0, -10.0, 20.0)]; + let (top, side) = (Vec3::new(0.0, 0.0, 20.0), Vec3::new(10.0, 0.0, 10.0)); + let cylinder_area = 2.0 * PI * 10.0 * 4.0; + let ring = PI * (14.0 * 14.0 - 100.0); + let cases = [ + (ruled(RuledMode::Normal, 4.0, top), cylinder_area, "cylinder"), + (ruled(RuledMode::Tangent, 4.0, top), ring, "plane"), + (ruled(RuledMode::Angle { angle: 30f64.to_radians(), reference: None }, 4.0, top), PI * 4.0 * 22.0, "cone"), + (ruled(RuledMode::Direction(Vec3::new(0.0, 0.0, 2.0)), 4.0, top), cylinder_area, "cylinder"), + (ruled(RuledMode::Normal, 4.0, side), ring, "plane"), + (ruled(RuledMode::Tangent, 4.0, side), cylinder_area, "cylinder"), + ]; + for (r, want, kind) in cases { + let s = ruled_surface(&c, &rim, &r).unwrap(); + assert!(rel(area(&s), want) < 2e-3, "{r:?}: {} vs {want}", area(&s)); // (measured on meshes) + for i in 0..s.face_count() { + let got = face_surface(&s, i); + let ok = match kind { + "cylinder" => matches!(got, Some(FaceSurface::Cylinder { radius, .. }) if (radius - 10.0).abs() < 1e-6), + "cone" => matches!(got, Some(FaceSurface::Cone { half_angle, .. }) if (half_angle - 30f64.to_radians()).abs() < 1e-6), + _ => matches!(got, Some(FaceSurface::Plane { .. })), + }; + assert!(ok, "{r:?}: {got:?}"); + } + // The strips of the rim's arcs share their side edges: the strip closes round. + assert_eq!(shared(&s), s.face_count(), "{r:?}"); + } + } + + /// A closed chain round a box top (10 × 10), 2 wide: corners join with no gap or overlap. + /// Up the normal the strips share their corner edges (area 4·10·2). Across the top, mitred + /// they make a 14 × 14 frame less the top; rounded, 4·10·2 plus a quarter disc at each + /// corner. At 45° mitred, four trapezoids 2·(10 + √2); rounded, quarter cones π·√2·2. + /// Stitching the result sews nothing more: every join is already shared. + #[test] + fn a_closed_chain_round_a_box_top() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)]; + let top = Vec3::new(5.0, 5.0, 10.0); + let round = |m: RuledMode| Ruled { corners: RuledCorners::Round, ..ruled(m, 2.0, top) }; + let angle = RuledMode::Angle { angle: PI / 4.0, reference: None }; + let h = 2.0 * FRAC_1_SQRT_2; + let cases = [ + (ruled(RuledMode::Normal, 2.0, top), 80.0, 4, 4), + (ruled(RuledMode::Tangent, 2.0, top), 14.0 * 14.0 - 100.0, 4, 4), + (round(RuledMode::Tangent), 80.0 + PI * 4.0, 12, 12), + (ruled(angle, 2.0, top), 80.0 + 4.0 * 2.0 * h, 4, 4), + (round(angle), 80.0 + PI * h * 2.0, 12, 12), + ]; + for (r, want, faces, joins) in cases { + let s = ruled_surface(&b, &rim, &r).unwrap(); + assert!(rel(area(&s), want) < 2e-3, "{r:?}: {} vs {want}", area(&s)); // (measured on meshes) + assert_eq!((s.face_count(), shared(&s)), (faces, joins), "{r:?}"); + // The strips stand on the box's own edges. + let own: std::collections::HashSet = b.solid.edge_iter().map(|e| e.id()).collect(); + assert_eq!(uses(&s).keys().filter(|e| own.contains(e)).count(), 4); + let sewn = crate::stitch(&[&s], 0.0).unwrap(); + assert_eq!((sewn.face_count(), shared(&sewn), uses(&sewn).len()), (faces, joins, uses(&s).len()), "{r:?}"); + } + } + + /// An inside corner (an L-shaped prism's top: 20 × 10 and 10 × 10) is cut back, mitred or + /// rounded: across the top 2 wide, the mitred frame is 24 × 24 less a 10 × 10 notch less + /// the L (476 − 300); rounded, the five outside corners are quarter discs and the inside + /// one stays mitred (80·2 − 2² + 5·π·2²/4). + #[test] + fn an_inside_corner_is_cut_back() { + let outer = Loop2::polygon(&[ + Vec2::ZERO, + Vec2::new(20.0, 0.0), + Vec2::new(20.0, 10.0), + Vec2::new(10.0, 10.0), + Vec2::new(10.0, 20.0), + Vec2::new(0.0, 20.0), + ]); + let ell = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap(); + let rim: Vec = + [(10.0, 0.0), (20.0, 5.0), (15.0, 10.0), (10.0, 15.0), (5.0, 20.0), (0.0, 10.0)].iter().map(|(x, y)| Vec3::new(*x, *y, 10.0)).collect(); + let top = Vec3::new(5.0, 5.0, 10.0); + let mitre = ruled_surface(&ell, &rim, &ruled(RuledMode::Tangent, 2.0, top)).unwrap(); + assert!(rel(area(&mitre), 176.0) < 1e-9, "{}", area(&mitre)); + let round = ruled_surface(&ell, &rim, &Ruled { corners: RuledCorners::Round, ..ruled(RuledMode::Tangent, 2.0, top) }).unwrap(); + let want = 156.0 + 5.0 * PI; + assert!(rel(area(&round), want) < 2e-3, "{} vs {want}", area(&round)); // (measured on meshes) + } + + /// A curved edge a cut left (a box side cut by a cylinder of radius 40: an arc of it on the + /// side face, 2·40·asin(14/40) long), with strips 3 wide out of that face and continuing + /// the cut: 3 times its length each. + #[test] + fn an_edge_a_cut_left() { + let b = box_solid(Vec3::new(-14.0, -14.0, 0.0), Vec3::new(14.0, 14.0, 20.0)).unwrap(); + let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap(); + let cup = crate::boolean(&b, &tool, crate::BoolOp::Cut).unwrap().unwrap(); + let z = 50.0 - (1600.0f64 - 0.0).sqrt(); + let (rim, side, cut) = ([Vec3::new(0.0, -14.0, z)], Vec3::new(0.0, -14.0, 5.0), Vec3::new(0.0, 0.0, z)); + let want = 3.0 * 80.0 * (14.0f64 / 40.0).asin(); + for r in [ruled(RuledMode::Normal, 3.0, side), ruled(RuledMode::Tangent, 3.0, cut)] { + let s = ruled_surface(&cup, &rim, &r).unwrap(); + assert!(rel(area(&s), want) < 2e-3, "{r:?}: {} vs {want}", area(&s)); // (measured on meshes) + let bb = measure(&s).unwrap().bbox; + assert!((bb.min.y + 17.0).abs() < 1e-6 && (bb.max.y + 14.0).abs() < 1e-6, "{r:?}: {bb:?}"); + } + } + + /// Off a doubly curved face (a saddle patch over a cup's rim) its normal turns along a + /// straight edge: the strip is a fitted ruled B-spline, its rulings square to the edge, so + /// it reaches exactly 3 from the edge's line (within the fit) and covers it end to end. + #[test] + fn a_strip_off_a_curved_face_is_fitted() { + let b = box_solid(Vec3::new(-14.0, -14.0, 0.0), Vec3::new(14.0, 14.0, 20.0)).unwrap(); + let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap(); + let cup = crate::boolean(&b, &tool, crate::BoolOp::Cut).unwrap().unwrap(); + let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt(); + let rim = [Vec3::new(14.0, 0.0, z(14.0)), Vec3::new(0.0, 14.0, z(0.0)), Vec3::new(-14.0, 0.0, z(-14.0)), Vec3::new(0.0, -14.0, z(0.0))]; + let cover = crate::patch_edges(&cup, &rim).unwrap(); + let edge = [Vec3::new(14.0, 0.0, z(14.0))]; + let s = ruled_surface(&cover, &edge, &ruled(RuledMode::Normal, 3.0, Vec3::new(0.0, 0.0, 12.0))).unwrap(); + assert!(s.face_count() == 1 && face_surface(&s, 0).is_none(), "{:?}", face_surface(&s, 0)); + let mesh = s.tessellate(0.01).unwrap(); + let from_line = |p: &Vec3| Vec3::new(p.x - 14.0, 0.0, p.z - z(14.0)).len(); + let far = mesh.positions.iter().map(from_line).fold(0.0, f64::max); + assert!((far - 3.0).abs() < 2e-3, "{far}"); + let bb = measure(&s).unwrap().bbox; + assert!((bb.min.y + 14.0).abs() < 1e-3 && (bb.max.y - 14.0).abs() < 1e-3, "{bb:?}"); + } + + /// Each strip is made in place of the edge it stands on; a corner face in place of the two + /// edges meeting at it. + #[test] + fn strips_come_from_their_edges() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0)]; + let r = Ruled { corners: RuledCorners::Round, ..ruled(RuledMode::Tangent, 2.0, Vec3::new(5.0, 5.0, 10.0)) }; + let s = ruled_surface(&b, &rim, &r).unwrap(); + let h = s.history().unwrap(); + let edges = |k: usize| h.faces.get(k).unwrap().origins.iter().filter(|o| matches!(o, FaceOrigin::Edge { operand: 0, .. })).count(); + let counts: Vec = (0..s.face_count()).map(edges).collect(); + assert_eq!(counts.iter().filter(|n| **n == 1).count(), 2, "{counts:?}"); + assert!(counts.iter().all(|n| *n == 1 || *n == 2), "{counts:?}"); + } + + /// Hostile settings are refused, never a panic: a zero, negative or non-finite distance, a + /// non-finite angle, no direction, a direction along the edge, edges that branch. + #[test] + fn hostile_settings_are_refused() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let edge = [Vec3::new(5.0, 0.0, 10.0)]; + let top = Vec3::new(5.0, 5.0, 10.0); + for d in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e300] { + assert!(ruled_surface(&b, &edge, &ruled(RuledMode::Normal, d, top)).is_err(), "{d}"); + } + for m in [ + RuledMode::Angle { angle: f64::NAN, reference: None }, + RuledMode::Angle { angle: 0.5, reference: Some(Vec3::ZERO) }, + RuledMode::Angle { angle: 0.5, reference: Some(Vec3::X) }, + RuledMode::Direction(Vec3::ZERO), + RuledMode::Direction(Vec3::X), + RuledMode::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), + ] { + assert!(ruled_surface(&b, &edge, &ruled(m, 2.0, top)).is_err(), "{m:?}"); + } + // Three edges meeting at a corner branch. + let corner = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(0.0, 5.0, 10.0), Vec3::new(0.0, 0.0, 5.0)]; + assert!(ruled_surface(&b, &corner, &ruled(RuledMode::Normal, 2.0, top)).is_err()); + assert!(ruled_surface(&b, &[], &ruled(RuledMode::Normal, 2.0, top)).is_err()); + assert!(ruled_surface(&b, &[Vec3::new(500.0, 0.0, 0.0)], &ruled(RuledMode::Normal, 2.0, top)).is_err()); + } +} diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 0ca9d542..e43e2126 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -415,6 +415,17 @@ pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result { /// The edges of `body` picked by points on them, chained into one closed loop (each turned to /// run along it). Call it under [`guard`]: sampling an intersection curve can fail in truck. pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result> { + match picked_chain(body, at)? { + (chain, true) => Ok(chain), + (_, false) => Err(KernelError::Invalid("the edges do not form a closed loop".into())), + } +} + +/// The edges of `body` picked by points on them, chained end to end (each turned to run along +/// the chain), and whether the chain closes: a closed loop starts with the last edge picked; an +/// open chain runs from the free end met first in pick order. Edges that branch, or form more +/// than one chain, are refused. Call it under [`guard`], as [`picked_loop`]. +pub(crate) fn picked_chain(body: &Body, at: &[Vec3]) -> Result<(Vec, bool)> { let mut edges: Vec = Vec::new(); for p in at { let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; @@ -422,25 +433,40 @@ pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result> { edges.push(e); } } + let mut uses: HashMap = HashMap::new(); + for e in &edges { + *uses.entry(e.front().id()).or_default() += 1; + *uses.entry(e.back().id()).or_default() += 1; + } + if uses.values().any(|n| *n > 2) { + return Err(KernelError::Invalid("the edges branch: pick one chain".into())); + } + let free = |v: &mt::Vertex| uses.get(&v.id()) == Some(&1); + let open_end = edges.iter().find_map(|e| [e.front(), e.back()].into_iter().find(|v| free(v)).cloned()); let mut pool = edges; - let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?; + let (first, closed) = match &open_end { + Some(v) => { + let k = pool.iter().position(|e| e.front().id() == v.id() || e.back().id() == v.id()); + let e = k.map(|k| pool.remove(k)).ok_or_else(|| KernelError::Invalid("no edges".into()))?; + (if e.front().id() == v.id() { e } else { e.inverse() }, false) + } + None => (pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?, true), + }; let start = first.front().id(); let mut at_v = first.back().clone(); let mut chain = vec![first]; - while at_v.id() != start { - let k = pool - .iter() - .position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id()) - .ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?; + while !(closed && at_v.id() == start) { + let Some(k) = pool.iter().position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id()) else { break }; let e = pool.remove(k); let e = if e.front().id() == at_v.id() { e } else { e.inverse() }; at_v = e.back().clone(); chain.push(e); } if !pool.is_empty() { - return Err(KernelError::Invalid("the edges form more than one loop".into())); + let what = if closed { "the edges form more than one loop" } else { "the edges form more than one chain" }; + return Err(KernelError::Invalid(what.into())); } - Ok(chain) + Ok((chain, closed)) } /// Faces of a body copied out as a surface body (faces picked by points on them). Its history is @@ -695,10 +721,24 @@ fn map_pt(off: &Offset, p: mt::Point3, d: f64) -> mt::Point3 { /// A curve moved by an offset (control points mapped; exact for lines, and for circles square /// to a cylinder's axis or in a plane). pub(crate) fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result { - Ok(match c { - mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map_pt(off, l.0, d), map_pt(off, l.1, d))), + if let Some(moved) = map_control_points(c, |p| off.map(p, d)) { + return Ok(moved); + } + // Anything else: a cubic through moved samples. + let (t0, t1) = c.range_tuple(); + let pts: Vec = (0..=32).map(|k| off.map(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 32.0)), d)).collect(); + Ok(mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?)) +} + +/// A line, B-spline or NURBS curve with its control points mapped by `f` (the exact image of +/// the curve under an affine `f`); `None` for a curve without control points (an +/// intersection curve). +pub(crate) fn map_control_points(c: &mt::Curve, f: impl Fn(Vec3) -> Vec3) -> Option { + let map = |p: mt::Point3| p3(f(from_p3(p))); + Some(match c { + mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map(l.0), map(l.1))), mt::Curve::BSplineCurve(b) => { - let pts: Vec = b.control_points().iter().map(|p| map_pt(off, *p, d)).collect(); + let pts: Vec = b.control_points().iter().map(|p| map(*p)).collect(); mt::Curve::BSplineCurve(mt::BSplineCurve::new(b.knot_vec().clone(), pts)) } mt::Curve::NurbsCurve(n) => { @@ -708,18 +748,13 @@ pub(crate) fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result { - // Anything else: a cubic through moved samples. - let (t0, t1) = other.range_tuple(); - let pts: Vec = (0..=32).map(|k| off.map(from_p3(other.subs(t0 + (t1 - t0) * k as f64 / 32.0)), d)).collect(); - mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?) - } + mt::Curve::IntersectionCurve(_) => return None, }) } diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 74a83aae..ce5cf23b 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -1,10 +1,10 @@ //! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and -//! Loft, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature. +//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature. use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; -use solidcraft_engine::doc::{AxisRef, Direction, FaceAt, FeatureKind, Mat, PlaneRef, ProfileSel}; +use solidcraft_engine::doc::{AxisRef, Direction, FaceAt, FeatureKind, Mat, PlaneRef, ProfileSel, RuledCorners, RuledMode}; use solidcraft_engine::geom::Vec3; use crate::SolveApp; @@ -34,6 +34,14 @@ pub enum Surf { distance: String, chain: bool, }, + /// `mode`: an index into [`RULED_MODES`]. + Ruled { + distance: String, + mode: usize, + angle: String, + flip: bool, + round: bool, + }, Stitch { tolerance: String, }, @@ -69,6 +77,7 @@ impl Surf { Surf::Unstitch => "UNSTITCH", Surf::ReverseNormal => "REVERSE NORMAL", Surf::Offset { .. } => "OFFSET SURFACE", + Surf::Ruled { .. } => "RULED SURFACE", Surf::Stitch { .. } => "STITCH", Surf::ConvertMesh { .. } => "CONVERT MESH", Surf::Thicken { .. } => "THICKEN", @@ -83,7 +92,9 @@ impl Surf { pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match self { Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness), - Surf::Extend { distance } | Surf::Extrude { distance, .. } | Surf::Offset { distance, .. } => ("Distance", ValueKind::Length, distance), + Surf::Extend { distance } | Surf::Extrude { distance, .. } | Surf::Offset { distance, .. } | Surf::Ruled { distance, .. } => { + ("Distance", ValueKind::Length, distance) + } Surf::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), _ => return None, }) @@ -96,6 +107,9 @@ const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"]; /// The sides a surface extrude goes to (labels, and the `direction` parameter). const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric", "symmetric"), ("Flipped", "negative")]; +/// The ways a ruled surface leaves its edges (labels, and the `mode` parameter). +const RULED_MODES: [(&str, &str); 4] = [("Normal", "normal"), ("Tangent", "tangent"), ("Angle", "angle"), ("Direction", "direction")]; + fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], @@ -106,6 +120,9 @@ fn inputs(k: &Surf) -> Vec { Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)], Surf::Offset { .. } => vec![SelInput::new("Faces", FACES, true)], + Surf::Ruled { .. } => { + vec![SelInput::new("Edges", EDGES, true), SelInput::new("Reference face", FACES, false), SelInput::new("Direction", AXES | EDGES, false)] + } Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)], Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)], Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)], @@ -131,6 +148,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "surface.unstitch" => Surf::Unstitch, "surface.reverse_normal" => Surf::ReverseNormal, "surface.offset" => Surf::Offset { distance: "2 mm".into(), chain: false }, + "surface.ruled" => Surf::Ruled { distance: "10 mm".into(), mode: 0, angle: "5 deg".into(), flip: false, round: false }, "surface.stitch" => Surf::Stitch { tolerance: String::new() }, "mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() }, "surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 }, @@ -191,6 +209,27 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { ui.checkbox(chain, ""); ui.end_row(); } + Surf::Ruled { distance, mode, angle, flip, round } => { + row_label(ui, "Distance"); + enter |= field(ui, distance); + ui.end_row(); + row_label(ui, "Type"); + combo(ui, "ru_mode", &RULED_MODES.map(|(label, _)| label), mode); + ui.end_row(); + if RULED_MODES.get(*mode).is_some_and(|(_, m)| *m == "angle") { + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + } + row_label(ui, "Corners"); + let mut corner = usize::from(*round); + combo(ui, "ru_corners", &["Mitre", "Round"], &mut corner); + *round = corner == 1; + ui.end_row(); + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } Surf::Extrude { distance, direction } => { row_label(ui, "Distance"); enter |= field(ui, distance); @@ -377,6 +416,45 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + need(0, "the edges to rule from")?; + let edge_body = |x: &Sel| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None }; + let body = items(inputs, 0).iter().find_map(edge_body).ok_or("pick edges of a body")?; + if items(inputs, 0).iter().any(|x| edge_body(x).as_ref() != Some(&body)) { + return Err("pick edges of one body".into()); + } + let edges: Vec = + items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect(); + let mode = RULED_MODES.get(*mode).map_or("normal", |(_, m)| *m); + let mut p = json!({"body": body, "edges": edges, "distance": distance, "mode": mode}); + if mode == "angle" { + p["angle"] = json!(angle); + } + if let Some(Sel::Face { body: fb, point, .. }) = items(inputs, 1).first() { + if *fb != body { + return Err("the reference face must be on the edges' body".into()); + } + p["face"] = pt(*point); + } + // A picked direction, else the one the feature was made with (Edit Feature). + let kept = extra.get("direction").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2])); + let dir = match items(inputs, 2).first() { + Some(x) => Some(crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1), + None => kept, + }; + match (mode, dir) { + ("angle" | "direction", Some(d)) => p["direction"] = pt(d), + ("direction", None) => return Err("select a direction (an axis, a sketch line or a straight edge) first".into()), + _ => {} + } + if *flip { + p["flip"] = json!(true); + } + if *round { + p["corners"] = json!("round"); + } + ("surface.ruled", p) + } Surf::Unstitch | Surf::ReverseNormal => { need(0, "a body or some of its faces")?; let (body, faces) = body_and_faces(items(inputs, 0))?; @@ -465,7 +543,8 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map Option<(K FeatureKind::SurfaceOffset { faces, distance, chain, .. } => { (Surf::Offset { distance: distance.clone(), chain: *chain }, vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect()]) } + FeatureKind::SurfaceRuled { edges, distance, mode, angle, direction, flip, corners, face, .. } => { + let k = Surf::Ruled { + distance: distance.clone(), + mode: match mode { + RuledMode::Normal => 0, + RuledMode::Tangent => 1, + RuledMode::Angle => 2, + RuledMode::Direction => 3, + }, + angle: angle.clone().unwrap_or_else(|| "5 deg".into()), + flip: *flip, + round: *corners == RuledCorners::Round, + }; + let mut ins = inputs(&k); + let fill = [edges.iter().filter_map(edge).collect(), face.and_then(|p| crate::dialogs::face_sel(s, p)).into_iter().collect()]; + for (inp, items) in ins.iter_mut().zip(fill) { + inp.items = items; + } + let mut extra = Map::new(); + if let Some(d) = direction { + extra.insert("direction".into(), pt(*d)); + } + return Some((Kind::Surface(k), ins, extra)); + } FeatureKind::Unstitch { body, faces } | FeatureKind::ReverseNormal { body, faces } => { let sels: Vec = if faces.is_empty() { vec![Sel::Body { name: body.clone() }] diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 7ae56dc7..6a8ac545 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -968,6 +968,46 @@ fn surface_loft_dialog_makes_a_section_per_sketch() { assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]); } +#[test] +fn ruled_surface_dialog_takes_edges_a_face_and_a_direction() { + // A box top's front and right edges, ruled across the top 2 mm with round corners: two + // 10 × 2 strips and a quarter disc between them. Edit Feature brings it back; along a + // direction it needs one, and keeps the one it was made with. + let mut app = app_with(json!([{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Box"}}])); + let mut d = start(&mut app, vec![], "surface.ruled"); + assert!(apply_commands(&app, &d).is_err(), "no edges yet"); + let edge = |i: usize, p: Vec3| Sel::Edge { body: "Box".into(), index: i, point: p }; + d.inputs[0].items = vec![edge(0, Vec3::new(5.0, 0.0, 10.0)), edge(1, Vec3::new(10.0, 5.0, 10.0))]; + d.inputs[1].items = vec![Sel::Face { body: "Box".into(), index: 0, point: Vec3::new(5.0, 5.0, 10.0) }]; + if let Kind::Surface(crate::dialogs_surface::Surf::Ruled { distance, mode, round, .. }) = &mut d.kind { + *distance = "2 mm".into(); + *mode = 1; + *round = true; + } + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!( + (id.as_str(), &p["body"], &p["mode"], &p["corners"], &p["face"]), + ("surface.ruled", &json!("Box"), &json!("tangent"), &json!("round"), &json!([5.0, 5.0, 10.0])), + "{p}" + ); + app.session.execute(&id, &p).unwrap(); + let m = app.session.model.state().bodies.last().unwrap().mesh().measure(); + let want = 40.0 + std::f64::consts::PI; + assert!((m.area - want).abs() < want * 2e-3, "{} vs {want}", m.area); // (measured on meshes) + let fid = app.session.doc.features.last().unwrap().id; + let mut back = crate::dialogs::for_feature(&app, fid, None).unwrap(); + assert_eq!((back.inputs[0].items.len(), back.inputs[1].items.len()), (2, 1)); + assert!(matches!(back.kind, Kind::Surface(crate::dialogs_surface::Surf::Ruled { mode: 1, round: true, .. }))); + if let Kind::Surface(crate::dialogs_surface::Surf::Ruled { mode, .. }) = &mut back.kind { + *mode = 3; + } + assert!(apply_commands(&app, &back).is_err(), "a direction is needed"); + back.inputs[2].items = vec![Sel::Axis { name: "Z".into() }]; + // Edit Feature runs the command's parameters as an edit of the feature. + let (_, p) = apply_commands(&app, &back).unwrap().remove(0); + assert_eq!((&p["params"]["mode"], &p["params"]["direction"]), (&json!("direction"), &json!([0.0, 0.0, 1.0])), "{p}"); +} + #[test] fn untrim_dialog_takes_a_face_its_holes_and_the_outer_boundary() { // A 40 × 20 × 2 plate with three holes: its top untrimmed (every hole) is the whole 800 mm² diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 45516fae..a1876011 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -19,6 +19,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::SurfaceSweep { .. } => "surface_sweep", FeatureKind::SurfaceLoft { .. } => "surface_loft", FeatureKind::SurfaceOffset { .. } => "surface_offset", + FeatureKind::SurfaceRuled { .. } => "ruled", FeatureKind::SurfaceUntrim { .. } => "untrim", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", diff --git a/docs/features.md b/docs/features.md index fcd7a5e4..d624bc61 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **364 commands.** Planned (registered, not built yet): 5. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **365 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (99) @@ -146,7 +146,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (13, 4 planned) +## Surfaces (14, 3 planned) | Command | Id | Toolbar | |---|---|---| @@ -160,7 +160,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Surface Sweep | `surface.sweep` | SURFACE › CREATE | | Surface Loft | `surface.loft` | SURFACE › CREATE | | Offset Surface | `surface.offset` | SURFACE › CREATE | -| Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE | +| Ruled Surface | `surface.ruled` | SURFACE › CREATE | | Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE | | Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE | | Untrim | `surface.untrim` | SURFACE › MODIFY |