diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 97dca7e5..6221864b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -36,6 +36,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | +| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 | +| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 | | docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 | diff --git a/ROADMAP.md b/ROADMAP.md index 1887bc9c..67e1ba71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,11 +53,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | +| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 21e4125c..97780eb4 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -563,6 +563,10 @@ pub enum FeatureKind { /// What to do when the radius is too large for the geometry around the edges. #[serde(default, skip_serializing_if = "FilletFit::is_exact")] fit: FilletFit, + /// What fills the corners where three blended edges meet: the rolling ball's sphere + /// (default) or a G2 patch repeating each blend up to the second order. + #[serde(default, skip_serializing_if = "FilletCorner::is_rolling")] + corner: FilletCorner, }, Chamfer { edges: Vec, @@ -1311,6 +1315,10 @@ pub enum FeatureKind { /// sketch curves: walls only, no caps. SurfaceLoft { sections: Vec, + #[serde(default = "loft_plain_continuity", skip_serializing_if = "loft_continuity_is_plain")] + continuity: [Continuity; 2], + #[serde(default, skip_serializing_if = "loft_crown_is_plain")] + crown: [f64; 2], }, /// The faces at `faces` copied out at a signed `distance` along their normals as a new /// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces; @@ -1491,6 +1499,23 @@ pub enum FeatureKind { }, } +/// What fills the corner where three blended edges meet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilletCorner { + /// A piece of the rolling ball's sphere: tangent (G1) to each blend. + #[default] + Rolling, + /// A G2 patch repeating each blend's surface up to the second order (see `corner_g2`). + G2, +} + +impl FilletCorner { + pub fn is_rolling(&self) -> bool { + matches!(self, FilletCorner::Rolling) + } +} + /// How Extend continues a surface past its edges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1734,6 +1759,28 @@ pub struct SheetSection { pub profiles: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub curves: Vec, + /// A section picked as an edge chain of a body instead of a sketch: `edges` are points on the + /// chain, and the face the chain bounds is the surface the loft leaves there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edges: Vec, +} + +/// A loft's continuity where a design does not say: both ends G0, the loft of old, which only +/// passes through its sections. +fn loft_plain_continuity() -> [Continuity; 2] { + [Continuity::G0, Continuity::G0] +} + +/// A loft's continuity is plain when it only passes through its sections: both ends G0. +fn loft_continuity_is_plain(c: &[Continuity; 2]) -> bool { + *c == [Continuity::G0, Continuity::G0] +} + +/// A loft's crown is plain when neither end is filled out. +fn loft_crown_is_plain(c: &[f64; 2]) -> bool { + c.iter().all(|v| *v == 0.0) } /// Coil cross-sections. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 45f8ce45..40cde9bd 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4551,8 +4551,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: }; st.blend_edges.insert(f.id, edges.clone()); let input = mb.traced(); + // A G2 corner is built by the corner construction alone, for a constant radius: any + // other style (or a fitted radius) has no corner patch to repeat. + if let FeatureKind::Fillet { corner: crate::FilletCorner::G2, style, fit, .. } = &f.kind + && (!matches!(style, crate::FilletStyle::Constant) || !matches!(fit, crate::FilletFit::Exact)) + { + return Err(DocError::Invalid("a G2 corner is built for a constant-radius fillet (type: constant, fit: exact) only".into())); + } // The blended body, and the lineage of what the last blend started from. let (nb, from) = match &f.kind { + FeatureKind::Fillet { corner: crate::FilletCorner::G2, .. } => (kernel::fillet_g2_corners(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => { @@ -5238,6 +5246,7 @@ mod chain_tests { body: None, style: crate::FilletStyle::Constant, fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index d6c8a573..80917515 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -3,7 +3,7 @@ //! `surface_offset`, `surface_ruled`, `surface_untrim`). use super::*; -use crate::{RuledCorners, RuledMode}; +use crate::{RuledCorners, RuledMode, SheetSection}; use solidcraft_geom::Seg2; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { @@ -313,7 +313,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, push_sheet(st, f, traced); Ok(()) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, continuity, crown } => { + // A loft with edge chains for sections, or one that has to leave its ends a particular + // way, is built through `loft_blend` from the frames of every section; the plain one + // (sketch chains only, both ends G0) is the loft of old. + if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) { + return loft_blend(st, f, sections, *continuity, *crown, doc); + } let mut secs = Vec::with_capacity(sections.len()); let mut sketches = Vec::with_capacity(sections.len()); for sec in sections { @@ -553,3 +559,118 @@ fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result>> { } Ok(chains) } + +/// A multi-section blend: every section as frames, the body ones read off the face their edge +/// chain bounds, and the first and last leaving with `continuity` where the caller asks for it. +fn loft_blend( + st: &mut ModelState, + f: &Feature, + sections: &[SheetSection], + continuity: [kernel::Continuity; 2], + crown: [f64; 2], + doc: &Document, +) -> Result<()> { + /// One section: a chain of sketch curves in its plane, or a body's edge chain. + enum Piece { + Sketch(solidcraft_geom::Plane, Vec), + Body(String, Vec), + } + let mut pieces = Vec::with_capacity(sections.len()); + let mut centres = Vec::with_capacity(sections.len()); + for sec in sections { + if let Some(name) = sec.body.as_deref() { + if sec.edges.is_empty() { + return Err(DocError::Invalid("a loft section picked from a body needs a chain of its edges".into())); + } + let n = sec.edges.len() as f64; + centres.push(sec.edges.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + **p) * (1.0 / n)); + pieces.push(Piece::Body(name.to_string(), crate::plain(&sec.edges))); + continue; + } + let ss = sketch_in_feature_frame( + doc, + st.sketch(sec.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?, + f.component, + )?; + let profiles = match (&sec.profiles, sec.curves.is_empty()) { + (None, true) => Some(&ProfileSel::All), + (p, _) => p.as_ref(), + }; + let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter(); + let (Some(chain), None) = (chains.next(), chains.next()) else { + return Err(DocError::Invalid("each loft section must be one chain: one profile without holes, or curves meeting end to end".into())); + }; + let pts: Vec = chain.iter().flat_map(|s| s.polyline(1e-2)).collect(); + let centre = pts.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + ss.plane.to_world(*p)) * (1.0 / pts.len().max(1) as f64); + centres.push(centre); + pieces.push(Piece::Sketch(ss.plane, chain)); + } + // Each section leaves towards the one after it; the last one leaves back into the blend. + let stations = BLEND_STATIONS; + let last = pieces.len().saturating_sub(1); + let mut frames = Vec::with_capacity(pieces.len()); + for (i, piece) in pieces.iter().enumerate() { + let (from, to) = if i < last { (i, i + 1) } else { (last, last.saturating_sub(1)) }; + let leave = match (centres.get(from), centres.get(to)) { + (Some(a), Some(b)) => *b - *a, + _ => solidcraft_geom::Vec3::ZERO, + }; + match piece { + Piece::Body(name, edges) => { + let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?.body.clone(); + frames.push(kernel::loft_blend::section_frames(&b, edges, leave, stations)?); + } + Piece::Sketch(plane, chain) => frames.push(chain_frames(plane, chain, leave, stations)), + } + } + let body = kernel::loft_blend(&frames, continuity, crown, stations)?; + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::new(name, body, f.id)); + Ok(()) +} + +/// The frames of one sketch chain, sampled by arclength at `stations` stations: a sketch has no +/// face behind it, so the surface leaves along the run with no curvature to repeat there. +fn chain_frames( + plane: &solidcraft_geom::Plane, + chain: &[solidcraft_geom::Seg2], + leave: solidcraft_geom::Vec3, + stations: usize, +) -> Vec { + let mut pts: Vec = Vec::new(); + for seg in chain { + for p in seg.polyline(1e-2) { + if pts.last().is_none_or(|l: &Vec2| (*l - p).len() > 1e-12) { + pts.push(p); + } + } + } + if pts.len() < 2 || stations < 2 { + return Vec::new(); + } + let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum(); + if !(total > 1e-12) { + return Vec::new(); + } + let step = total / (stations - 1) as f64; + let (mut walked, mut k) = (0.0f64, 0usize); + let mut out = Vec::with_capacity(stations); + for i in 0..stations { + let want = step * i as f64; + while k + 1 < pts.len() && walked + (pts[k + 1] - pts[k]).len() < want { + walked += (pts[k + 1] - pts[k]).len(); + k += 1; + } + let (Some(a), Some(b)) = (pts.get(k).copied(), pts.get(k + 1).copied()) else { + if let Some(p) = pts.get(k).copied() { + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + continue; + }; + let span = (b - a).len(); + let t = if span > 1e-12 { ((want - walked) / span).clamp(0.0, 1.0) } else { 0.0 }; + let p = a * (1.0 - t) + b * t; + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + out +} diff --git a/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs b/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs new file mode 100644 index 00000000..d736c40c --- /dev/null +++ b/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs @@ -0,0 +1,7 @@ +//! The Class-A surface pass also lets a `Fillet` carry `corner` (`rolling`, the default and what +//! every older design meant, or `g2`: a patch repeating each blend's surface up to the second +//! order) and a `SurfaceLoft` carry `continuity` and `crown` and body edge-chain `sections` (a +//! section picked as a `body` with `edges` instead of a `sketch`). A design without them saves as +//! it always did — a loft without `continuity` reads and writes as `[g0, g0]`, the plain loft it +//! always was — so older designs need no transformation; 0.13.x asks for an update instead of +//! meeting a feature it can't read or dropping a corner style it does not round. diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3dab4b2..9dbaad6e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -113,6 +113,7 @@ fn box_fillet_cut_timeline() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -183,6 +184,7 @@ fn errors_are_reported_not_fatal() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 7c571a1b..66135aaf 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -50,6 +50,7 @@ impl Walk for crate::document::Operation {} impl Walk for solidcraft_kernel::Continuity {} impl Walk for crate::document::Direction {} impl Walk for crate::document::FilletFit {} +impl Walk for crate::document::FilletCorner {} impl Walk for crate::document::MeshData {} impl Walk for crate::document::RevolveConvention {} impl Walk for crate::document::CoilSection {} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 923ac485..ed5843a3 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("fillet") .key("F") - .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) @@ -1051,10 +1051,26 @@ fn fillet(s: &mut Session, p: &Value) -> Result { if !fit.is_exact() && !style.is_constant() { return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius")); } + // What fills the corners where three blended edges meet. + let corner = match str_(p, "corner").map(str::to_ascii_lowercase).as_deref() { + None | Some("rolling") => solidcraft_doc::FilletCorner::Rolling, + Some("g2") => solidcraft_doc::FilletCorner::G2, + Some(o) => return Err(bad(cmd, format!("unknown corner `{o}` (rolling or g2)"))), + }; + if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) { + return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only")); + } let mut out = add_feature( s, p, - FeatureKind::Fillet { edges: edges.into_iter().map(Position).collect(), radius, body: str_(p, "body").map(BodyName::from), style, fit }, + FeatureKind::Fillet { + edges: edges.into_iter().map(Position).collect(), + radius, + body: str_(p, "body").map(BodyName::from), + style, + fit, + corner, + }, )?; // The radii the fillet used, when `fit: reduce` reduced it. if let Some(id) = out.get("feature").and_then(Value::as_u64) @@ -2042,6 +2058,55 @@ mod thin_extrude_tests; #[cfg(test)] #[path = "extrude_convention_tests.rs"] mod extrude_convention_tests; +/// `solid.fillet corner?: rolling|g2`: the default keeps the rolling ball's sphere corner, `g2` +/// is accepted and refuses (item 10's patch is built but not sewn in yet, see `corner_g2`), and an +/// unknown word is refused. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod corner_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: &Value) -> Value { + s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) + } + + /// A 20 mm box with its three edges at one corner, filleted by 4 with `corner`. + fn cornered(corner: Option<&str>) -> (Session, Value) { + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let mut p = json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4}); + if let Some(c) = corner { + p["corner"] = json!(c); + } + let reply = run(&mut s, "solid.fillet", &p); + (s, reply) + } + + #[test] + fn the_default_corner_is_the_rolling_ball_and_g2_refuses_clearly() { + let (default, reply) = cornered(None); + assert_eq!(reply["warning"], json!(null), "{reply}"); + let (rolling, _) = cornered(Some("rolling")); + let ball = default.world_state().body("B").unwrap().body.clone(); + let same = rolling.world_state().body("B").unwrap().body.clone(); + // `corner: rolling` is the default: the same body, to the last bit of its area. + assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area); + assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0); + // `corner: g2`: accepted, and refused with a clear message until the patch sews in. + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let err = + s.execute("solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2"})).unwrap_err().to_string(); + eprintln!("corner g2: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + // An unknown word, and a style the G2 corner has no patch for, are refused as parameters. + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "round"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "type": "chord"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "fit": "reduce"})).is_err()); + } +} #[cfg(test)] #[path = "fillet_connected_rounds_tests.rs"] mod fillet_connected_rounds_tests; diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index c4eebc0d..7b17fe4d 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -88,7 +88,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("blend") .enabled(in_sketch) - .params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())), + .params(concat!( + "a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)", + created_doc!() + )), CommandSpec::new("sketch.centerline", "Centerline", centerline) .at("SKETCH", "CREATE") .icon("centerline") @@ -399,8 +402,68 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> { sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d))) } +/// The continuity a blend asks for: the number of derivatives of each reference curve it +/// matches at both joints (g1 is the tangent construction the tool has always made). +fn blend_order(p: &Value, cmd: &str) -> Result { + match p.get("continuity") { + None | Some(Value::Null) => Ok(1), + Some(Value::String(v)) => match v.trim().to_ascii_lowercase().as_str() { + "" | "g1" => Ok(1), + "g2" => Ok(2), + "g3" => Ok(3), + "g4" => Ok(4), + _ => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + }, + Some(_) => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + } +} + +/// Negate the odd-order derivatives: how a curve's derivatives change when it is read backwards. +fn flip_odd(d: &mut [Vec2]) { + for (i, v) in d.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } +} + +/// Derivatives 1…`order` of curve `ci`'s continuation through its end point `pi`, in the +/// parameterisation the blend needs at that joint: at the blend's start (`leaving`) the curve's +/// continuation runs away from it, at the blend's end the blend runs into the curve instead, which +/// is the same curve read backwards. The derivatives are those of the one exact piece that ends +/// at `pi`, evaluated at that piece's own end, so an arc gives its circular-motion terms (zero +/// curvature rate included) and a conic the quotient rule's terms. Errors when the curve has no +/// exact piece, when a derivative is not finite (a corrupt or degenerate reference), or when it +/// has no tangent at all: no blend can meet the last case. +fn joint_derivatives(sk: &Sketch, ci: usize, pi: usize, order: usize, leaving: bool, what: &str, cmd: &str) -> Result> { + let id = sk.curves.get(ci).map(|c| c.id.as_str()).unwrap_or("?"); + let (start, end) = sk.curves.get(ci).and_then(|c| c.kind.ends()).ok_or_else(|| bad(cmd, format!("`{id}` has no end points")))?; + let at_end = end == pi; + if !at_end && start != pi { + return Err(bad(cmd, format!("`{what}` is not an end point of `{id}`"))); + } + let pieces = sk.exact_pieces(ci); + let seg = if at_end { pieces.last() } else { pieces.first() }.ok_or_else(|| bad(cmd, format!("`{id}` has no exact geometry")))?; + let mut d = seg + .derivatives(if at_end { 1.0 } else { 0.0 }, order) + .ok_or_else(|| bad(cmd, format!("`{id}` has no finite derivatives up to order {order} at `{what}`")))?; + // Reading the piece against its own direction, or reading the continuation into the curve + // rather than out of it, each reverse the parameterisation once and so flip the odd + // derivatives: the blend's start wants the continuation away from the reference's end (no + // flip) or backwards out of its start (flip); its end wants the mirror image of that, so a + // joint needs a flip exactly when its two readings disagree. + if at_end != leaving { + flip_odd(&mut d); + } + if !d.first().is_some_and(|t| t.len() > 0.0) { + return Err(bad(cmd, format!("`{id}` has no tangent at `{what}`"))); + } + Ok(d) +} + fn blend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.blend_curve"; + let order = blend_order(p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?; let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?; @@ -412,10 +475,34 @@ fn blend(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the ends must differ")); } let first_new = sk.points.len(); - let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; - let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; - let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + let (sp, cons) = if order <= 1 { + let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; + let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + (sp, cons) + } else { + // One Bézier of degree 2·order + 1 whose controls are the Hermite interpolation of the + // two references' derivatives, so its 1st…order-th derivatives are theirs at both + // joints. Tangency comes from the construction, so it needs no tangent constraints. + let da = joint_derivatives(sk, ca, pa, order, true, "a", cmd)?; + let db = joint_derivatives(sk, cb, pb, order, false, "b", cmd)?; + let mut pts: Vec = Vec::with_capacity(2 * order + 2); + let cps = solidcraft_geom::hermite_bezier(qa, qb, &da, &db) + .ok_or_else(|| bad(cmd, "the blend curve could not be built from the derivatives of `a` and `b`"))?; + for (i, q) in cps.iter().enumerate() { + pts.push(if i == 0 { + pa + } else if i + 1 == cps.len() { + pb + } else { + sk.add_point(*q, None)? + }); + } + let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?; + let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false }, None)?; + (sp, Vec::new()) + }; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) })?; @@ -860,3 +947,391 @@ pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) { } } } + +#[cfg(test)] +mod blend_tests { + use serde_json::{Value, json}; + use solidcraft_geom::{Seg2, Vec2}; + use solidcraft_sketch::CurveKind; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn ids(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + } + + fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s + } + + fn sketch(s: &Session) -> solidcraft_sketch::Sketch { + let id = s.active_sketch.unwrap(); + s.model.state().sketch(id).unwrap().sketch.clone() + } + + fn factorial(n: usize) -> f64 { + (1..=n).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` of exact piece `seg` at its end (`at_end`), from the piece's own + /// geometry: what a blend must match, computed here independently of `Seg2::derivatives`. + fn piece_derivatives(seg: &Seg2, at_end: bool, order: usize) -> Vec { + let t = if at_end { 1.0 } else { 0.0 }; + let p = seg.point_at(t); + let mut d = match *seg { + Seg2::Line { a, b } => vec![b - a, Vec2::ZERO, Vec2::ZERO, Vec2::ZERO], + Seg2::Arc { center, sweep, .. } => { + let (v, s2) = (seg.derivative(t), sweep * sweep); + vec![v, (p - center) * -s2, v * -s2, (p - center) * (s2 * s2)] + } + Seg2::Cubic { p0, p1, p2, p3 } => { + let third = (p3 - p2 * 3.0 + p1 * 3.0 - p0) * 6.0; + if at_end { + vec![(p3 - p2) * 3.0, (p3 - p2 * 2.0 + p1) * 6.0, third, Vec2::ZERO] + } else { + vec![(p1 - p0) * 3.0, (p0 - p1 * 2.0 + p2) * 6.0, third, Vec2::ZERO] + } + } + Seg2::Conic { a, apex, b, w } => { + // The rational quadratic's numerator N and weight D as quadratics in the power + // basis; dividing N's series by D's (constant term 1 at both ends) has to give the + // Taylor coefficients of the curve. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let h = 1e-3; + let series = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = series(&|x| n0 + n1 * x + n2 * (x * x)); + let den = [1.0 + e1 * t + e2 * (t * t), e1 + e2 * (2.0 * t), e2]; + let mut x = vec![n[0] / den[0]]; + for j in 1..=order { + let mut v = n.get(j).copied().unwrap_or(Vec2::ZERO); + for i in 1..=j { + v -= x[j - i] * den.get(i).copied().unwrap_or(0.0); + } + x.push(v / den[0]); + } + x.remove(0); + x.into_iter().enumerate().map(|(i, v)| v * factorial(i + 1)).collect() + } + Seg2::Bezier { .. } => panic!("a test piece is not a high-degree Bézier"), + }; + d.truncate(order); + d + } + + /// The derivatives 1…`order` the blend's own control polygon carries at an end: the j-th + /// difference of the controls, scaled by `n!/(n − j)!` (the Bézier endpoint identity). + fn blend_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + /// The blend curve's control points, its degree and the one exact piece they make. + fn blend_of(s: &Session, id: &str) -> (Vec, usize, Seg2) { + let sk = sketch(s); + let ci = sk.curve_index(id).unwrap(); + let ctrl: Vec = sk.curves[ci].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect(); + let degree = match sk.curves[ci].kind { + CurveKind::Spline { degree, .. } => usize::from(degree), + ref other => panic!("{other:?}"), + }; + let pieces = sk.exact_pieces(ci); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + (ctrl, degree, pieces[0]) + } + + /// The reference pair the tests build: a line `(0, 0)`–`(6, 0)` ending at `a` and a three-point + /// control spline starting at `b`, so the blend runs out of the first and into the second with + /// no reversal at either joint. + fn two_references(s: &mut Session) -> (String, String, [Vec2; 4]) { + let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [6, 0]]}))["curves"])[0].clone(); + let bctrl = [Vec2::new(20.0, 10.0), Vec2::new(24.0, 14.0), Vec2::new(30.0, 9.0), Vec2::new(34.0, 6.0)]; + let b = ids(&run(s, "sketch.spline.control_point", json!({"points": [[20, 10], [24, 14], [30, 9], [34, 6]]}))["curves"])[0].clone(); + (a, b, bctrl) + } + + #[test] + fn blend_curve_matches_both_references_through_the_requested_order() { + for order in 1..=4usize { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1, "g{order} is one Bézier of degree 2k+1"); + assert_eq!(ctrl.len(), 2 * order + 2, "g{order} control count"); + assert!(r["sketch"]["solved"].as_bool().unwrap_or(false), "{r}"); + assert!(piece.start().dist(Vec2::new(6.0, 0.0)) < 1e-12 && piece.end().dist(bctrl[0]) < 1e-12, "the blend joins `a` and `b`"); + + // What each reference supplies at the joint: the line's own derivative at its end (and + // nothing higher) and the cubic's at its start. + let line = piece_derivatives(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(6.0, 0.0) }, true, 4); + let cubic = piece_derivatives(&Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }, false, 4); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + // g1 is the tangent construction of the tool: the first derivative is parallel to the + // reference's (that is what G1 is), with the tool's own chord-scaled magnitude. g2 and + // up match the reference's derivative vectors themselves. + let tan_line = line[0].normalized().unwrap(); + let tan_cubic = cubic[0].normalized().unwrap(); + assert!(got0[0].cross(tan_line).abs() < 1e-9 && got0[0].dot(tan_line) > 0.0, "g{order} leaves `a` along {tan_line:?}, not {:?}", got0[0]); + assert!( + got1[0].cross(tan_cubic).abs() < 1e-9 && got1[0].dot(tan_cubic) > 0.0, + "g{order} enters `b` along {tan_cubic:?}, not {:?}", + got1[0] + ); + for j in 1..order { + assert!(got0[j].dist(line[j]) < 1e-9, "g{order} at a, order {}: {:?} vs {:?}", j + 1, got0[j], line[j]); + assert!(got1[j].dist(cubic[j]) < 1e-9, "g{order} at b, order {}: {:?} vs {:?}", j + 1, got1[j], cubic[j]); + } + if order >= 2 { + for (got, want) in [(got0[0], line[0]), (got1[0], cubic[0])] { + assert!(got.dist(want) < 1e-9, "g{order} matches the reference's own derivative: {got:?} vs {want:?}"); + } + } + // The next order up is not met: the line has no derivative there at all, while a blend of + // degree 2k+1 has. + if order < 4 { + let next = got0[order]; + assert!(next.dist(line[order]) > 1e-6, "g{order} matched one order too many at a: {next:?}"); + } + // Curvature itself (orientation-free, from finite differences of the samples) agrees at + // both joints once the order asks for it. + if order >= 2 { + let curve = |x: f64| piece.point_at(x.clamp(0.0, 1.0)); + let line_f = |x: f64| Vec2::new(6.0 * x, 0.0); + let cubic_f = |x: f64| Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }.point_at(x); + let k0 = solidcraft_sketch::end_curvature(&curve, false); + let ka = solidcraft_sketch::end_curvature(&line_f, true); + let k1 = solidcraft_sketch::end_curvature(&curve, true); + let kb = solidcraft_sketch::end_curvature(&cubic_f, false); + assert!(k0.dist(ka) < 1e-3, "g{order} curvature at a: {k0:?} vs {ka:?}"); + assert!(k1.dist(kb) < 1e-3, "g{order} curvature at b: {k1:?} vs {kb:?}"); + } + } + } + + #[test] + fn blend_curve_g1_is_the_tangent_construction_with_its_constraints() { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")})); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 3, "the default is the cubic tangent spline"); + let (qa, qb) = (Vec2::new(6.0, 0.0), bctrl[0]); + let l = qa.dist(qb); + let tb = (qb - bctrl[1]).normalized().unwrap(); + let want = [qa, qa + Vec2::X * (l / 3.0), qb + tb * (l / 3.0), qb]; + for (i, q) in want.iter().enumerate() { + assert!(ctrl[i].dist(*q) < 1e-9, "g1 control {i}: {:?} vs {q:?}", ctrl[i]); + } + assert_eq!(ids(&r["constraints"]).len(), 2, "the two tangents are kept: {r}"); + // g2…g4 meet the derivatives by construction and need no constraint of their own. + for order in 2..=4usize { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + assert!(ids(&r["constraints"]).is_empty(), "g{order}: {r}"); + } + } + + #[test] + fn blend_curve_between_two_lines_is_curvature_free_to_the_requested_order() { + // Two lines meeting at a right angle: from G2 on, the blend has the lines' own zero + // curvature at both joints (a corner with no curvature jump). + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + assert!(got0[0].dist(Vec2::new(2.0, 0.0)) < 1e-12, "g{order} leaves `a` along +x: {:?}", got0[0]); + assert!(got1[0].dist(Vec2::new(0.0, 2.0)) < 1e-12, "g{order} enters `b` along +y: {:?}", got1[0]); + for j in 1..order { + assert!(got0[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + assert!(got1[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + } + if order < 4 { + assert!(got0[order].len() > 1e-6, "g{order}: the blend curves, the line does not"); + } + assert!(piece.start().dist(Vec2::new(2.0, 0.0)) < 1e-12 && piece.end().dist(Vec2::new(20.0, 10.0)) < 1e-12); + } + } + + #[test] + fn blend_curve_matches_a_conic_reference() { + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.conic", json!({"start": [20, 10], "apex": [26, 16], "end": [30, 10], "rho": 0.7}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + // The conic's own derivatives at its start: `w` comes from the sketch's `rho`, and the + // series division in `piece_derivatives` is the expectation. + let w = 0.7 / (1.0 - 0.7); + let conic = Seg2::Conic { a: Vec2::new(20.0, 10.0), apex: Vec2::new(26.0, 16.0), b: Vec2::new(30.0, 10.0), w }; + let want = piece_derivatives(&conic, false, order); + let got = blend_derivatives(&ctrl, true, order); + for j in 0..order { + assert!(got[j].dist(want[j]) < 1e-8 * want[j].len().max(1.0), "g{order} order {}: {:?} vs {:?}", j + 1, got[j], want[j]); + } + } + } + + #[test] + fn blend_curve_refuses_hostile_orders_and_degenerate_references() { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let ends = json!({"a": format!("{a}.end"), "b": format!("{b}.start")}); + for (order, want) in [("g0", true), ("g9", true), ("G9", true), ("curvature", true), ("", false), ("g2", false), ("G4", false)] { + let mut p = ends.clone(); + p["continuity"] = json!(order); + let r = s.execute("sketch.blend_curve", &p); + assert_eq!(r.is_err(), want, "continuity {order:?}: {r:?}"); + assert!(r.is_ok() || r.unwrap_err().to_string().contains("continuity"), "continuity {order:?} needs a clear error"); + } + let mut numbered = ends.clone(); + numbered["continuity"] = json!(3); + assert!(s.execute("sketch.blend_curve", &numbered).is_err(), "a number is not a continuity"); + // A point that is not an end of a curve, and the same end twice. + let p = ids(&run(&mut s, "sketch.point", json!({"point": [50, 50]}))["created"]["points"])[0].clone(); + let e = s.execute("sketch.blend_curve", &json!({"a": p, "b": format!("{b}.start")})).unwrap_err().to_string(); + assert!(e.contains("no curve ends at `a`"), "{e}"); + let e = s.execute("sketch.blend_curve", &json!({"a": format!("{a}.end"), "b": format!("{a}.end")})).unwrap_err().to_string(); + assert!(e.contains("ends must differ"), "{e}"); + // The helper behind the command refuses a corrupt (non-finite) reference and a reference + // with no tangent at all, never guessing at them. + let mut broken = solidcraft_sketch::Sketch::new(); + let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap(); + let p1 = broken.add_point(Vec2::ZERO, None).unwrap(); + let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false }, None).unwrap(); + broken.points[p0].pos = Vec2::new(f64::NAN, 4.0); + assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry"); + let mut flat = solidcraft_sketch::Sketch::new(); + let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false }, None).unwrap(); + let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string(); + assert!(e.contains("no tangent"), "{e}"); + assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4"); + } + + #[test] + fn blend_curve_with_g4_closes_a_profile_that_measures_exactly() { + // A quarter-turn blend from (2, 0) into a line at (20, 10), closed by the chord between its + // ends: the region's area comes from the degree-9 curve, not from its chord. + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": "g4", + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 9, "G4 is one degree-9 Bézier"); + assert_eq!(ctrl.len(), 10); + // The command still reports what it added, with stable ids: ten controls of which the two + // joints are the references' own points, so eight new points, and no new constraint. + assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}"); + assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}"); + assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}"); + run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"})); + // Drop the two references: what is left is the blend and its chord, one closed region. + run(&mut s, "sketch.delete", json!({"entities": [a, b]})); + let id = s.active_sketch.unwrap(); + let state = s.model.state(); + let profiles = &state.sketch(id).unwrap().profiles; + assert_eq!(profiles.len(), 1, "the blend and the chord close one region"); + let area = profiles[0].area; + // A fine sampling of the exact piece plus the straight chord reproduces the area; the chord + // alone (what a polyline approximation would give) is exactly zero. + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + sampled += 0.5 * piece.point_at(t0).cross(piece.point_at(t1)); + } + // The chord back from (20, 10) to (2, 0) contributes its own shoelace term; together they + // are the region. + sampled += 0.5 * Vec2::new(20.0, 10.0).cross(Vec2::new(2.0, 0.0)); + assert!((area - sampled.abs()).abs() < 1e-4, "{area} vs {sampled}"); + assert!(area > 1e-2, "the blend curves away from the chord: {area}"); + // And the degree-9 profile still extrudes to its area times the height (the kernel's + // volume of a free-form body is a numerical integration, so this is the same 2e-4 the + // neighbouring free-form extrusion test allows). + run(&mut s, "solid.extrude", json!({"distance": 5})); + let m = run(&mut s, "inspect.measure", json!({})); + let volume = m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((volume - area * 5.0).abs() / (area * 5.0) < 2e-4, "volume {volume} vs {}", area * 5.0); + assert!(m["bodies"][0]["faces"].as_u64().unwrap_or(0) >= 3, "a solid with the blend as a side: {m}"); + } +} diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs index 1f3b1543..bb8afe8c 100644 --- a/crates/engine/src/cmd/sketch_support.rs +++ b/crates/engine/src/cmd/sketch_support.rs @@ -5,6 +5,11 @@ use solidcraft_sketch::{Shape, intersections}; use crate::{Result, params::bad}; +/// Degrees the exact extend intersection handles: a round support squares the piece's degree in +/// homogeneous form, and both the root finder and its `evaluate` buffer hold at most 15 +/// coefficients, so 2·7 + 1 fits. +const MAX_EXACT_EXTEND_DEGREE: u8 = 7; + fn choose(n: usize, k: usize) -> f64 { (0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64) } @@ -113,7 +118,7 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result, Vec) = match *seg { Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=MAX_EXACT_EXTEND_DEGREE).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), _ => return Err(bad("sketch.extend", "invalid exact curve piece")), }; if points.iter().any(|p| !p.is_finite()) { @@ -195,8 +200,8 @@ mod tests { elevated.push(y[n - 1]); y = elevated; } - let mut points = [Vec2::ZERO; 8]; - for (i, p) in points.iter_mut().enumerate() { + let mut points = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; + for (i, p) in points.iter_mut().take(8).enumerate() { *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); } let target = Seg2::Bezier { n: 7, p: points }; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 4e76e0d4..bde3ffa2 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.loft", "Surface Loft", surface_loft) .at("SURFACE", "CREATE") .icon("surface_loft") - .params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), + .params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), CommandSpec::new("surface.offset", "Offset Surface", offset) .at("SURFACE", "CREATE") .icon("surface_offset") @@ -369,15 +369,57 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result { } let mut sections = Vec::with_capacity(given.len()); for sec in given { + // A section is a chain of sketch curves, or an edge chain of a body whose face the end of + // the loft repeats. + if let Some(body) = str_(sec, "body") { + let edges: Vec = + sec.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, "`sections.edges` must list 1 to 10000 points on the edges of a chain")); + } + if sec.get("sketch").is_some() { + return Err(bad(cmd, "a section is either a sketch chain or a body edge chain")); + } + sections.push(solidcraft_doc::SheetSection { + sketch: SketchId(0), + profiles: None, + curves: Vec::new(), + body: Some(BodyName::from(body.to_string())), + edges: edges.into_iter().map(Position).collect(), + }); + continue; + } let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?; let curves = string_list(sec, "curves"); if curves.len() > 1000 { return Err(bad(cmd, "at most 1000 curves a section")); } let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None }; - sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves }); + sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves, body: None, edges: Vec::new() }); } - add_feature(s, p, FeatureKind::SurfaceLoft { sections }) + let continuity = match p.get("continuity") { + None => [Continuity::G0, Continuity::G0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?; + let word = |x: Option<&Value>| -> Result { + x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3")) + }; + [word(list.first())?, word(list.get(1))?] + } + }; + let crown = match p.get("crown") { + None | Some(Value::Null) => [0.0, 0.0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`crown` must be two numbers, one per end"))?; + let one = |x: Option<&Value>| -> Result { + x.and_then(Value::as_f64) + .filter(|x| x.is_finite() && (-0.9..=3.0).contains(x)) + .ok_or_else(|| bad(cmd, "each `crown` entry must be between -0.9 (flattest) and 3 (fullest)")) + }; + [one(list.first())?, one(list.get(1))?] + } + }; + add_feature(s, p, FeatureKind::SurfaceLoft { sections, continuity, crown }) } fn offset(s: &mut Session, p: &Value) -> Result { @@ -1212,7 +1254,7 @@ mod tests { /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { - let planned = ["surface.fillet", "inspect.continuity"]; + let planned = ["surface.fillet"]; 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(); @@ -1588,4 +1630,68 @@ mod tests { assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err()); assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err()); } + + /// A loft through three sections whose first and last are edge chains of two boxes, with + /// `continuity: ["g1","g1"]`: it leaves each box's top face tangentially (the run direction + /// lies in that face) and passes through the sketch between them. + #[test] + fn surface_loft_leaves_a_box_face_tangentially() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Low"})); + run(&mut s, "solid.box", json!({"corner": [40, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "High"})); + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 30, "name": "Mid"})); + run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "Bump"})); + run(&mut s, "sketch.line", json!({"points": [[0, 14], [20, 14]], "ids": ["b"]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "surface.loft", + json!({ + "sections": [ + {"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, + {"sketch": "Bump", "curves": ["b"]}, + {"body": "High", "edges": [[40, 5, 10], [40, 15, 10]]} + ], + "continuity": ["g1", "g1"], + "crown": [0, 0], + "body_name": "Blend" + }), + ); + let body = s.world_state().body("Blend").unwrap().body.clone(); + // A fine grid in the run direction: the seams sit at u = 0, 1/2 and 1, and the first step + // off each one is the tangent there (a thousandth of the run, so it is the tangent). + let grid = solidcraft_kernel::sample_grid(&body, 1001, 9).unwrap(); + // The ends lie on the two boxes' top faces, and the sketch is met exactly between them. + for p in &grid[0] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 20.0).abs() < 1e-9, "the first seam is the box edge: {p:?}"); + } + for p in &grid[1000] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 40.0).abs() < 1e-9, "the last seam is the box edge: {p:?}"); + } + for p in &grid[500] { + assert!((p.z - 14.0).abs() < 1e-9 && (p.x - 30.0).abs() < 1e-9, "the blend meets the sketch: {p:?}"); + } + // The seam leaves along the box's top face: the run direction the blend is given is what + // the face holds, so the tangent there is horizontal and along the run. (The step the grid + // takes is a thousandth of the run, and a chord over that step carries an O(h²) bias of + // about 0.011 here, so 1e-2 on the rise off the face is what the grid can measure; the + // kernel's own tests pin the tangent to the frame's leave direction exactly.) + for j in [0usize, 4, 8] { + let d = (grid[1][j] - grid[0][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend leaves the low box's top face: {d:?}"); + let d = (grid[1000][j] - grid[999][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend arrives along the high box's top face: {d:?}"); + } + // Hostile parameters are refused, never panicking. + let bad = [ + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1"]}), + json!({"sections": [{"body": "Low", "edges": []}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1", "g1"]}), + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10]], "sketch": "Bump"}, {"sketch": "Bump", "curves": ["b"]}]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "crown": [5, 0]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g9", "g1"]}), + ]; + for p in bad { + assert!(s.execute("surface.loft", &p).is_err(), "hostile loft params: {p}"); + } + } } diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index dbcea9f0..b0f6e456 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -5,8 +5,19 @@ use serde_json::{Value, json}; use solidcraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, bool_, num, vec3}; -use crate::{Result, Session, SurfaceAnalysis}; +use crate::params::{bad, bool_, num, str_, vec3}; +use crate::{ContinuitySeam, Result, Session, SurfaceAnalysis, kernel}; + +/// Stations along each edge when the call does not say (`samples`). +const DEFAULT_SAMPLES: usize = 33; +/// Tolerances a seam is judged against when the call does not say: a micron of position gap, a +/// degree of normal angle, and a curvature jump of 1/100 mm. +const DEFAULT_GAP_MM: f64 = 1e-3; +const DEFAULT_ANGLE_DEG: f64 = 1.0; +const DEFAULT_CURVATURE_PER_MM: f64 = 0.01; +/// Most edges one call may pick, and most bodies one call may analyse at once. +const MAX_PICKS: usize = 512; +const MAX_BODIES: usize = 64; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra) @@ -34,11 +45,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("curvature_map") .noundo() .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), - planned!("inspect.continuity", "Continuity Analysis", "770") - .at("SURFACE", "INSPECT") - .icon("continuity") - .noundo() - .params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"), + CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params( + "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ + a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \ + gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \ + clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \ + {value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \ + the seams are shaded in the viewport", + ), ]; fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { @@ -94,12 +108,214 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result { set(s, p, SurfaceAnalysis::Curvature { radius }) } +/// `inspect.continuity`: measure how far the two faces meeting at each picked edge (or at every +/// edge of a body) depart from sharing a curve (G0), a tangent plane (G1) and a curvature (G2), +/// and keep the seams so the viewport can shade them. View state only: the design and its undo +/// history are untouched (`noundo`), as with the other analyses. +fn continuity(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.continuity"; + if bool_(p, "clear").unwrap_or(false) { + s.continuity.clear(); + s.revision += 1; + return Ok(json!({ "analysis": "continuity", "cleared": true })); + } + let samples = match p.get("samples") { + None => DEFAULT_SAMPLES, + Some(v) => match v.as_f64().filter(|n| n.is_finite() && n.fract() == 0.0 && (3.0..=257.0).contains(n)) { + Some(n) => n as usize, + None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")), + }, + }; + let tolerances = kernel::ContinuityTolerances { + gap_mm: tolerance(p, "gap_tolerance_mm", DEFAULT_GAP_MM)?, + angle_deg: tolerance(p, "angle_tolerance_deg", DEFAULT_ANGLE_DEG)?, + curvature_per_mm: tolerance(p, "curvature_tolerance_per_mm", DEFAULT_CURVATURE_PER_MM)?, + }; + let picks = edge_picks(p)?; + let st = s.world_state(); + let named = str_(p, "body"); + let candidates: Vec<&solidcraft_doc::ModelBody> = match named { + Some(name) => vec![st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?], + None => st.bodies.iter().filter(|b| s.body_visible(&b.name)).collect(), + }; + if candidates.is_empty() { + return Err(bad(cmd, "there are no visible bodies to analyse: name a `body`")); + } + if candidates.len() > MAX_BODIES { + return Err(bad(cmd, format!("more than {MAX_BODIES} bodies are shown: name the `body` to analyse"))); + } + let mut targets: Vec<(&solidcraft_doc::ModelBody, Vec)> = Vec::new(); + if picks.is_empty() { + targets.extend(candidates.into_iter().map(|b| (b, Vec::new()))); + } else { + // Picks mean edges of one body (a design may stack bodies at the same place): a list + // that lands on one body's edges only is analysed, and anything else is refused. + let mut hits = Vec::new(); + let mut refused = None; + for b in candidates { + match kernel::pick_edges(&b.body, &picks) { + Ok(edges) => hits.push((b, edges)), + Err(e) => refused = refused.or(Some(e)), + } + } + if hits.is_empty() { + return Err(match (named, refused) { + (Some(_), Some(e)) => e.into(), + _ => bad(cmd, "no visible body has an edge matching `edges`"), + }); + } + if hits.len() > 1 { + return Err(bad(cmd, "`edges` matches edges of more than one body: name the `body` to analyse")); + } + targets.extend(hits); + } + let mut seams: Vec = Vec::new(); + let mut bodies = Vec::new(); + let mut summary = Summary::default(); + for (body, edges) in &targets { + let report = kernel::seam_diagnostics(&body.body, edges, samples, tolerances)?; + let mut rows = Vec::new(); + for seam in &report.seams { + summary.count(seam.verdict); + seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict }); + rows.push(json!({ + "edge": seam.edge, + "length_mm": seam.length, + "samples": seam.samples, + "gap_mm": seam.gap_mm, + "normal_angle_deg": seam.normal_angle_deg, + "curvature_jump_per_mm": seam.curvature_jump_per_mm, + "verdict": seam.verdict, + })); + } + summary.skipped += report.skipped; + summary.unmeasured += report.unmeasured; + bodies.push(json!({ + "body": body.name, + "edges": rows, + "skipped": report.skipped, + "unmeasured": report.unmeasured, + })); + } + s.continuity = seams; + s.revision += 1; + Ok(json!({ + "analysis": "continuity", + "samples": samples, + "tolerances": { + "gap_mm": tolerances.gap_mm, + "angle_deg": tolerances.angle_deg, + "curvature_per_mm": tolerances.curvature_per_mm, + }, + "bodies": bodies, + "summary": { + "edges": summary.edges, + "smooth": summary.smooth, + "curvature_jump": summary.curvature_jump, + "sharp": summary.sharp, + "gap": summary.gap, + "skipped": summary.skipped, + "unmeasured": summary.unmeasured, + "worst": summary.worst(), + }, + })) +} + +/// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a +/// string, a bare `null`) is refused: `num` would fold it into "not given". +fn tolerance(p: &Value, key: &str, default: f64) -> Result { + match p.get(key) { + None => Ok(default), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..1e9).contains(&x) => Ok(x), + _ => Err(bad("inspect.continuity", format!("`{key}` must be a finite number, 0 or more"))), + }, + } +} + +/// The `edges` parameter: points on the edges to measure, or `{"index": i}` entries in the +/// display mesh's edge order. Absent (or an empty list) means every edge of the body. +fn edge_picks(p: &Value) -> Result> { + let Some(value) = p.get("edges") else { return Ok(Vec::new()) }; + let Some(list) = value.as_array() else { + return Err(bad("inspect.continuity", "`edges` must be a list of [x,y,z] points or {\"index\": i} entries")); + }; + if list.len() > MAX_PICKS { + return Err(bad("inspect.continuity", format!("pick at most {MAX_PICKS} edges"))); + } + let mut out = Vec::new(); + for item in list { + if let Some(index) = item.get("index").and_then(Value::as_u64) { + let index = usize::try_from(index).map_err(|_| bad("inspect.continuity", "`index` is too large"))?; + out.push(kernel::EdgePick::Index(index)); + } else if let Some(point) = vec3(item) { + out.push(kernel::EdgePick::Point(point)); + } else { + return Err(bad("inspect.continuity", "each `edges` entry is a point [x,y,z] or {\"index\": i}")); + } + } + Ok(out) +} + +/// Counts of a continuity reply, and the worst verdict over every edge measured. +#[derive(Clone, Copy, Debug, Default)] +struct Summary { + edges: usize, + smooth: usize, + curvature_jump: usize, + sharp: usize, + gap: usize, + skipped: usize, + unmeasured: usize, +} + +impl Summary { + fn count(&mut self, verdict: kernel::SeamVerdict) { + self.edges += 1; + match verdict { + kernel::SeamVerdict::Smooth => self.smooth += 1, + kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1, + kernel::SeamVerdict::Sharp => self.sharp += 1, + kernel::SeamVerdict::Gap => self.gap += 1, + } + } + + /// The worst verdict over the edges measured (`None` when there was nothing to measure). + /// [`kernel::SeamVerdict`] runs from the best continuity to the worst. + fn worst(&self) -> Option { + [ + (self.smooth, kernel::SeamVerdict::Smooth), + (self.curvature_jump, kernel::SeamVerdict::CurvatureJump), + (self.sharp, kernel::SeamVerdict::Sharp), + (self.gap, kernel::SeamVerdict::Gap), + ] + .into_iter() + .filter(|(count, _)| *count > 0) + .map(|(_, verdict)| verdict) + .max() + } +} + #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::{Session, SurfaceAnalysis}; + fn boxed(s: &mut Session, size: f64) { + s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap(); + } + + /// The edges of the first body of a continuity reply. + fn edges(v: &Value) -> &Vec { + v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}")) + } + + /// The seams that read as tangent (within 0.01° of the neighbouring face). + fn tangent(edges: &[Value]) -> Vec<&Value> { + edges.iter().filter(|e| e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN) < 0.01).collect() + } + #[test] fn surface_analyses_are_view_state() { let mut s = Session::default(); @@ -121,4 +337,126 @@ mod tests { assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err()); assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err()); } + + /// A box edge: the faces meet exactly (G0) and square to each other (G1), so the analysis + /// must read 0 gap and 90°, and must keep its seams as view state without an undo step. + #[test] + fn continuity_reports_a_box_edge_as_a_gap_free_right_angle() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + let undo = s.undo.len(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 12); + for e in edges(&v) { + let gap = e["gap_mm"]["value"].as_f64().unwrap(); + let angle = e["normal_angle_deg"]["value"].as_f64().unwrap(); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!(gap < 1e-6, "gap {gap} in {e}"); + assert!((angle - 90.0).abs() < 1e-9, "angle {angle} in {e}"); + assert!(jump < 1e-9, "curvature jump {jump} in {e}"); + assert_eq!(e["verdict"], json!("sharp"), "{e}"); + assert_eq!(e["samples"], json!(33), "{e}"); + assert!((e["length_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{e}"); + assert_eq!(e["normal_angle_deg"]["at"].as_array().map(Vec::len), Some(3), "{e}"); + let f = e["normal_angle_deg"]["edge_fraction"].as_f64().unwrap(); + assert!((0.0..=1.0).contains(&f), "{e}"); + } + assert_eq!(v["analysis"], json!("continuity")); + assert_eq!(v["samples"], json!(33)); + assert_eq!(v["tolerances"], json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01})); + assert_eq!(v["summary"]["edges"], json!(12)); + assert_eq!(v["summary"]["sharp"], json!(12)); + assert_eq!(v["summary"]["worst"], json!("sharp")); + assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport"); + assert_eq!(s.undo.len(), undo, "no undo step"); + assert_eq!(s.analysis, None, "the other analyses are left alone"); + } + + /// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its + /// two seams read ~0° with the fillet's 1 / radius curvature jump against the planes. + #[test] + fn continuity_finds_the_fillet_seams_tangent_and_their_curvature_jump() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 15, "the rounded edge leaves three new edges behind: {v}"); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 2, "the fillet meets its two neighbours tangentially: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.25).abs() < 1e-6, "1 / radius 4: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(2)); + assert_eq!(v["summary"]["sharp"], json!(13)); + assert_eq!(v["summary"]["smooth"], json!(0)); + } + + /// A slot's straight wall runs tangentially into its arc wall: the join is G1 but its + /// curvature jumps by the arc's 1 / radius, which is exactly what a Class-A check looks for. + #[test] + fn continuity_reports_one_over_the_radius_where_a_line_meets_an_arc() { + let mut s = Session::default(); + s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); + s.execute("sketch.slot.center_point", &json!({"center": [0, 0], "end": [20, 0], "width": 10})).unwrap(); + s.execute("solid.extrude", &json!({"distance": 5})).unwrap(); + let v = s.execute("inspect.continuity", &json!({"angle_tolerance_deg": 0.01})).unwrap(); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 4, "a slot has four line-to-arc joins: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.2).abs() < 1e-6, "1 / radius 5: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(4)); + } + + /// `clear` takes the shading away, and every continuity call stays view state. + #[test] + fn continuity_is_view_state_and_clears() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + let undo = s.undo.len(); + s.execute("inspect.continuity", &json!({"samples": 5})).unwrap(); + assert_eq!(s.continuity.len(), 12); + assert_eq!(s.undo.len(), undo, "no undo step"); + let cleared = s.execute("inspect.continuity", &json!({"clear": true})).unwrap(); + assert_eq!(cleared["cleared"], json!(true)); + assert!(s.continuity.is_empty(), "clear leaves nothing shaded"); + assert_eq!(s.undo.len(), undo); + } + + /// Hostile parameters are refused with an error, never a panic. + #[test] + fn hostile_continuity_params_are_refused() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + for params in [ + json!({"samples": 0}), + json!({"samples": 2}), + json!({"samples": 258}), + json!({"samples": 3.5}), + json!({"samples": "33"}), + json!({"samples": null}), + json!({"gap_tolerance_mm": -1}), + json!({"gap_tolerance_mm": 1e12}), + json!({"angle_tolerance_deg": f64::NAN}), + json!({"curvature_tolerance_per_mm": "tight"}), + json!({"edges": 3}), + json!({"edges": [[1, 2]]}), + json!({"edges": [[]]}), + json!({"edges": [{"index": 99999}]}), + json!({"edges": [[500, 500, 500]]}), + json!({"body": "Nope"}), + ] { + assert!(s.execute("inspect.continuity", ¶ms).is_err(), "accepted {params}"); + } + let many: Vec = (0..513).map(|i| json!([i as f64, 0.0, 0.0])).collect(); + assert!(s.execute("inspect.continuity", &json!({"edges": many})).is_err(), "513 picks"); + assert!(s.continuity.is_empty(), "a refused call shades nothing"); + assert_eq!(s.undo.len(), 1, "only the box is an undo step"); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d714352c..b5c2da8d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -239,6 +239,9 @@ pub struct Session { pub section: Option<(Vec3, Vec3)>, /// Surface analysis shading the model (zebra, draft, curvature map). pub analysis: Option, + /// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not + /// part of the design and not an undo step. + pub continuity: Vec, /// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an /// undo step. A design brings its saved visibility when it opens (`Session::new`). pub visibility: Visibility, @@ -301,6 +304,16 @@ pub enum SurfaceAnalysis { Access { dir: Vec3 }, } +/// One seam measured by `inspect.continuity`, where the viewport shades it: the stations it was +/// sampled at, in world coordinates, and how it reads. View state only: the design is untouched. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct ContinuitySeam { + /// Sample points along the seam, in world coordinates. + pub points: Vec, + /// The seam's worst verdict along its length. + pub verdict: kernel::SeamVerdict, +} + impl SurfaceAnalysis { /// How a picture shades the faces for this analysis. pub fn mode(self) -> render::AnalysisMode { @@ -356,6 +369,7 @@ impl Session { sketch_clipboard: None, section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, saved_visibility: visibility.clone(), @@ -823,6 +837,7 @@ impl Session { sketch_clipboard: self.sketch_clipboard.clone(), section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, visibility: self.visibility.clone(), diff --git a/crates/geom/src/blend.rs b/crates/geom/src/blend.rs new file mode 100644 index 00000000..6466ff98 --- /dev/null +++ b/crates/geom/src/blend.rs @@ -0,0 +1,53 @@ +//! Blend curves: one Bézier that joins two curves with a requested continuity order, by Hermite +//! interpolation of each reference curve's position and derivatives at the joints. This is the +//! geometry `sketch.blend_curve` builds; it knows nothing about sketches or the document. + +use crate::{MAX_DERIVATIVE_ORDER, Vec2, profile::binom}; + +/// Control points of the Bézier of degree `2k + 1`, `k = d0.len()`, that starts at `p0` with +/// derivatives `d0` and ends at `p1` with derivatives `d1`, all with respect to its own parameter +/// on [0, 1]. The derivative vectors are the reference curves' at the joints, measured in their +/// own parameters and oriented the way the blend runs through the joint, so the blend agrees with +/// each reference through its k-th derivative (G1, G2, G3, G4 for k = 1…4). +/// +/// The two ends fix exactly half of the controls each: a Bézier's j-th forward difference is +/// `P⁽ʲ⁾(0)·(n − j)!/n!`, so the first `k + 1` controls follow from the start data and the last +/// `k + 1` from the end data, which meet exactly at the middle control. `None` when the ends +/// disagree on the order, the order is above [`MAX_DERIVATIVE_ORDER`], or a value is not finite. +pub fn hermite_bezier(p0: Vec2, p1: Vec2, d0: &[Vec2], d1: &[Vec2]) -> Option> { + let k = d0.len(); + if k != d1.len() || k > MAX_DERIVATIVE_ORDER || !p0.is_finite() || !p1.is_finite() || d0.iter().chain(d1).any(|d| !d.is_finite()) { + return None; + } + let n = 2 * k + 1; + let mut c = vec![Vec2::ZERO; n + 1]; + c[0] = p0; + for j in 1..=k { + // `Δʲp₀ = P⁽ʲ⁾(0)·(n − j)!/n!`, and solving it for `pⱼ` puts `(−1)^(j − i + 1)` on every + // control already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d0[j - 1] * scale; + for (i, p) in c.iter().take(j).enumerate() { + value += *p * (odd_sign(j - i) * binom(j, i)); + } + c[j] = value; + } + c[n] = p1; + for j in 1..=k { + // The same at the far end, reading the controls backwards: `Σᵢ(−1)ⁱ·C(j,i)·pₙ₋ᵢ` is + // `P⁽ʲ⁾(1)·(n − j)!/n!`, and solving that for `pₙ₋ⱼ` puts `(−1)^(j + i + 1)` on every control + // already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d1[j - 1] * (scale * odd_sign(j + 1)); + for (i, p) in c[n - j + 1..].iter().rev().enumerate() { + value += *p * (odd_sign(j + i) * binom(j, i)); + } + c[n - j] = value; + } + c.iter().all(|p| p.is_finite()).then_some(c) +} + +/// `(−1)^(m + 1)`: the sign an m-th difference expansion carries in a Bézier's endpoint identity. +fn odd_sign(m: usize) -> f64 { + if m % 2 == 1 { 1.0 } else { -1.0 } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 850f471f..347d150d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod arcfit; +pub mod blend; pub mod brep; pub mod bspline; pub mod compare; @@ -23,9 +24,10 @@ pub mod tspline; mod vec; pub use arcfit::MAX_FIT_PIECES; +pub use blend::hermite_bezier; pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair}; pub use plane::Plane; -pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier, regions_apart}; +pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart}; pub use vec::{Vec2, Vec3}; /// Geometric tolerance for coincidence tests (mm). diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 4ff22666..e1117898 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -38,26 +38,35 @@ pub enum Seg2 { b: Vec2, w: f64, }, - /// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree - /// splines. + /// Bézier of degree `n` (4…[`MAX_BEZIER_DEGREE`]) over `p[0..=n]` (the rest unused): pieces of + /// higher-degree splines. Bezier { n: u8, - p: [Vec2; 8], + p: [Vec2; MAX_BEZIER_CTRL], }, } -/// Highest degree of [`Seg2::Bezier`]. -pub const MAX_BEZIER_DEGREE: usize = 7; +/// Highest degree of [`Seg2::Bezier`]: a degree-9 piece is the single Bézier a G4 blend curve +/// (`sketch.blend_curve` with `continuity: "g4"`) is made of. +pub const MAX_BEZIER_DEGREE: usize = 9; -/// A Bézier segment over `pts` (2…8 points): a line, a cubic or a higher-degree piece. A -/// quadratic becomes a conic of weight 1. +/// Control points a [`Seg2::Bezier`] carries: its highest degree plus one (the unused tail is +/// zero). +pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1; + +/// Highest derivative order [`Seg2::derivatives`] hands out, and therefore the highest +/// continuity order a blend curve can be built for (4 = G4). +pub const MAX_DERIVATIVE_ORDER: usize = 4; + +/// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree +/// piece. A quadratic becomes a conic of weight 1. pub fn bezier(pts: &[Vec2]) -> Option { Some(match *pts { [a, b] => Seg2::Line { a, b }, [a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 }, [p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 }, - _ if pts.len() <= MAX_BEZIER_DEGREE + 1 => { - let mut p = [Vec2::ZERO; 8]; + _ if (2..=MAX_BEZIER_CTRL).contains(&pts.len()) => { + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; for (d, s) in p.iter_mut().zip(pts) { *d = *s; } @@ -86,6 +95,38 @@ fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec, Vec) { (left.last().copied().unwrap_or(Vec2::ZERO), left, right) } +/// Binomial coefficient `C(n, k)` for the small orders the derivative rules need (0 when +/// `k > n`). +pub(crate) fn binom(n: usize, k: usize) -> f64 { + if k > n { + return 0.0; + } + (0..k.min(n - k)).fold(1.0, |v, i| v * (n - i) as f64 / (i + 1) as f64) +} + +/// Derivatives 1…`order` of the Bézier over `c` (degree `c.len() - 1`) at `t`: the j-th forward +/// difference of the control polygon, scaled by `n!/(n − j)!` and evaluated by de Casteljau. +/// Exact in exact arithmetic, zero above the degree, `None` for non-finite input. +fn bezier_derivatives(c: &[Vec2], t: f64, order: usize) -> Option> { + if !t.is_finite() || c.is_empty() || c.iter().any(|p| !p.is_finite()) { + return None; + } + let n = c.len() - 1; + let mut diffs = c.to_vec(); + let mut scale = 1.0; + let mut out = Vec::with_capacity(order); + for j in 1..=order { + if diffs.len() < 2 { + out.push(Vec2::ZERO); + continue; + } + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(casteljau(&diffs, t).0 * scale); + } + Some(out) +} + /// Gauss–Legendre nodes and weights on [0, 1] (5 points). const GL5: [(f64, f64); 5] = [ (0.046_910_077_030_668, 0.118_463_442_528_095), @@ -96,7 +137,7 @@ const GL5: [(f64, f64); 5] = [ ]; impl Seg2 { - fn bez(n: u8, p: &[Vec2; 8]) -> &[Vec2] { + fn bez(n: u8, p: &[Vec2; MAX_BEZIER_CTRL]) -> &[Vec2] { &p[..(n as usize).clamp(1, MAX_BEZIER_DEGREE) + 1] } /// Derivative with respect to the parameter at `t` (Béziers and conics; lines and arcs too). @@ -121,6 +162,86 @@ impl Seg2 { } } } + /// Derivatives 1…`order` with respect to the segment's own parameter at `t`, exactly: a line + /// and an arc from their analytic form (an arc's are the circular-motion recurrence, zero + /// curvature terms included), a cubic and a higher-degree piece from the forward differences + /// of their controls, a conic through the quotient rule of its rational form. A blend curve + /// reads a reference curve's derivatives here to build a Hermite interpolation, so this is + /// exact at both ends (`t` = 0 and 1) and `None` — never a wrong number — when `order` is + /// above [`MAX_DERIVATIVE_ORDER`], `t` is not finite or a value is not finite, as with a + /// degenerate rational piece. + pub fn derivatives(&self, t: f64, order: usize) -> Option> { + if !t.is_finite() || order > MAX_DERIVATIVE_ORDER { + return None; + } + let mut d = match *self { + Seg2::Line { a, b } => { + if !a.is_finite() || !b.is_finite() { + return None; + } + let mut d = vec![b - a]; + d.resize(MAX_DERIVATIVE_ORDER, Vec2::ZERO); + d + } + Seg2::Arc { center, radius, start, sweep } => { + if !center.is_finite() || !radius.is_finite() || !start.is_finite() || !sweep.is_finite() { + return None; + } + // Differentiating the radial direction twice returns its negative, so the + // directions of the successive derivatives are tangential, radial, tangential… + // with alternating signs, each order picking up one more factor of the sweep. + let mut v = Vec2::from_angle(start + sweep * t).perp(); + let mut d = Vec::with_capacity(MAX_DERIVATIVE_ORDER); + for j in 1..=MAX_DERIVATIVE_ORDER { + d.push(v * (radius * sweep.powi(j as i32))); + v = v.perp(); + } + d + } + Seg2::Cubic { p0, p1, p2, p3 } => bezier_derivatives(&[p0, p1, p2, p3], t, MAX_DERIVATIVE_ORDER)?, + Seg2::Bezier { n, ref p } => bezier_derivatives(Self::bez(n, p), t, MAX_DERIVATIVE_ORDER)?, + Seg2::Conic { a, apex, b, w } => { + if !a.is_finite() || !apex.is_finite() || !b.is_finite() || !w.is_finite() { + return None; + } + // The conic is `N(t) / d(t)` with `N` the quadratic through `a`, `w·apex`, `b` and + // `d` its weight polynomial; both are evaluated here in the power basis (at the + // ends `d` = 1, so a join is never singular). The quotient rule in its + // derivative-of-a-product form gives the higher derivatives: the numerator of + // `x⁽ʲ⁾` is `N⁽ʲ⁾` minus the terms that already use `d` and a lower `x`. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let quotient = |j: usize| match j { + 0 => n0 + n1 * t + n2 * (t * t), + 1 => n1 + n2 * (2.0 * t), + 2 => n2 * 2.0, + _ => Vec2::ZERO, + }; + let weight = |j: usize| match j { + 0 => 1.0 + e1 * t + e2 * (t * t), + 1 => e1 + e2 * (2.0 * t), + 2 => e2 * 2.0, + _ => 0.0, + }; + let den = weight(0); + if !(den.abs() > 1e-300) { + return None; + } + let mut x = vec![quotient(0) / den]; + for j in 1..=MAX_DERIVATIVE_ORDER { + let mut v = quotient(j); + for i in 1..=j { + v -= x[j - i] * (weight(i) * binom(j, i)); + } + x.push(v / den); + } + x.remove(0); + x + } + }; + d.truncate(order); + d.iter().all(|q| q.is_finite()).then_some(d) + } pub fn start(&self) -> Vec2 { match *self { Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a, diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e607f32..39010d87 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -560,3 +560,161 @@ mod close_pairs { assert!(budget < 10_000_000, "work was counted"); } } + +mod blend { + use super::*; + + /// `j!` for the small orders the Hermite and quotient identities use. + fn fact(j: usize) -> f64 { + (1..=j).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` a Bézier's control polygon carries at its end `at_end`: the j-th + /// difference of the controls, scaled by `n!/(n − j)!`. This is the textbook endpoint identity + /// of a Bézier, written here independently of the library's. + fn control_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + // The reversed controls describe `P(1 − s)`, so reversing again to read the original + // parameterisation negates the odd orders. + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + #[test] + fn hermite_bezier_meets_the_derivative_data_at_both_ends() { + let (p0, p1) = (Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.5)); + let d0 = [Vec2::new(2.0, 1.0), Vec2::new(-0.5, 3.0), Vec2::new(1.5, 1.0), Vec2::new(0.25, -2.0)]; + let d1 = [Vec2::new(-1.0, 0.5), Vec2::new(2.0, -1.5), Vec2::new(-0.75, 0.5), Vec2::new(3.0, 1.25)]; + for k in 0..=MAX_DERIVATIVE_ORDER { + let cps = hermite_bezier(p0, p1, &d0[..k], &d1[..k]).unwrap(); + assert_eq!(cps.len(), 2 * k + 2, "degree 2k+1 has 2k+2 controls"); + assert_eq!(cps[0], p0); + assert_eq!(*cps.last().unwrap(), p1); + for (got, want) in control_derivatives(&cps, false, k).iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=0: {got:?} vs {want:?}"); + } + for (got, want) in control_derivatives(&cps, true, k).iter().zip(&d1[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=1: {got:?} vs {want:?}"); + } + // The piece is exactly the single Bézier `bezier` hands back, and its ends are the two + // joints. + let seg = bezier(&cps).unwrap(); + assert!(seg.start().dist(p0) < 1e-12 && seg.end().dist(p1) < 1e-12); + for (got, want) in seg.derivatives(0.0, k).unwrap().iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-10, "k={k} at t=0: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn hermite_bezier_refuses_hostile_data() { + let (p0, p1) = (Vec2::ZERO, Vec2::X); + let d = [Vec2::Y, Vec2::X]; + assert!(hermite_bezier(p0, p1, &d, &d).is_some()); + assert!(hermite_bezier(p0, p1, &d, &d[..1]).is_none(), "the ends must agree on the order"); + assert!(hermite_bezier(p0, p1, &[Vec2::ZERO; 5], &[Vec2::ZERO; 5]).is_none(), "order above G4"); + assert!(hermite_bezier(Vec2::new(f64::NAN, 0.0), p1, &[], &[]).is_none()); + assert!(hermite_bezier(p0, p1, &[Vec2::new(f64::INFINITY, 0.0)], &[Vec2::Y]).is_none()); + assert!(hermite_bezier(p0, p1, &[], &[Vec2::Y]).is_none()); + } + + #[test] + fn seg_derivatives_are_exact_for_lines_and_polynomial_pieces() { + let line = Seg2::Line { a: Vec2::new(-1.0, 2.0), b: Vec2::new(3.0, 5.0) }; + let d = line.derivatives(0.0, MAX_DERIVATIVE_ORDER).unwrap(); + assert!(d[0].dist(Vec2::new(4.0, 3.0)) < 1e-12); + assert_eq!(d[1].len() + d[2].len() + d[3].len(), 0.0, "a line has no curvature"); + let cubic = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(3.0, 9.0), p2: Vec2::new(7.0, -4.0), p3: Vec2::new(11.0, 2.0) }; + let ctrl = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 9.0), Vec2::new(7.0, -4.0), Vec2::new(11.0, 2.0)]; + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in cubic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&ctrl, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + for t in [0.0, 0.35, 1.0] { + assert!(cubic.derivatives(t, 1).unwrap()[0].dist(cubic.derivative(t)) < 1e-9, "t={t}"); + } + // A degree-9 piece (the G4 blend's own shape) at both ends. + let pts: Vec = (0..10).map(|i| Vec2::new(i as f64 * 0.7, (i as f64 * 1.3).sin())).collect(); + let seg = bezier(&pts).unwrap(); + assert!(matches!(seg, Seg2::Bezier { n: 9, .. })); + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in seg.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&pts, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn seg_derivatives_follow_the_circular_recurrence() { + let arc = Seg2::Arc { center: Vec2::new(2.0, -1.0), radius: 5.0, start: 0.7, sweep: -1.9 }; + let Seg2::Arc { center, sweep, .. } = arc else { panic!("an arc") }; + for t in [0.0, 0.4, 1.0] { + let d = arc.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + let p = arc.point_at(t); + let sw2 = sweep * sweep; + // On a circle every derivative follows from the second: P″ = −sw²(P − c), and the rest + // differentiate that relation, so a line and an arc have zero curvature rates. + assert!(d[1].dist((p - center) * -sw2) < 1e-9, "t={t}"); + assert!(d[2].dist(d[0] * -sw2) < 1e-9, "t={t}"); + assert!(d[3].dist(d[1] * -sw2) < 1e-9, "t={t}"); + assert!(d[0].dist(arc.derivative(t)) < 1e-9, "t={t}"); + } + } + + #[test] + fn seg_derivatives_of_a_conic_satisfy_the_quotient_identity() { + let conic = Seg2::Conic { a: Vec2::new(-2.0, 0.0), apex: Vec2::new(0.0, 3.0), b: Vec2::new(4.0, 0.5), w: 0.7 }; + let Seg2::Conic { a, apex, b, w } = conic else { panic!("a conic") }; + // The rational quadratic's numerator and weight polynomials, straight from their Bernstein + // form, and their power coefficients at `t` by differences (exact for a quadratic). + let n_at = |x: f64| a * ((1.0 - x) * (1.0 - x)) + apex * (2.0 * w * x * (1.0 - x)) + b * (x * x); + let d_at = |x: f64| (1.0 - x) * (1.0 - x) + 2.0 * w * x * (1.0 - x) + x * x; + for t in [0.0, 0.25, 1.0] { + let h = 1e-3; + // Taylor coefficients at `t` (differences are exact for a quadratic): f, f′/1!, f″/2!. + let coef_v = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let coef_s = |f: &dyn Fn(f64) -> f64| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = coef_v(&n_at); + let d = coef_s(&d_at); + let got = conic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + // Taylor coefficients of P: P(t) and P⁽ʲ⁾(t)/j!. Leibniz on N = P·D then reads + // Σ xᵢ·dⱼ₋ᵢ = nⱼ at every order, which the quotient rule must reproduce. + let x: Vec = std::iter::once(conic.point_at(t)).chain(got.iter().enumerate().map(|(i, v)| *v / fact(i + 1))).collect(); + for j in 0..=4usize { + let lhs: Vec2 = (0..=j).fold(Vec2::ZERO, |acc, i| acc + x[i] * d.get(j - i).copied().unwrap_or(0.0)); + let rhs = n.get(j).copied().unwrap_or(Vec2::ZERO); + assert!(lhs.dist(rhs) < 1e-8 * rhs.len().max(1.0), "t={t} j={j}: {lhs:?} vs {rhs:?}"); + } + assert!(got[0].dist(conic.derivative(t)) < 1e-8, "t={t}"); + } + } + + #[test] + fn seg_derivatives_refuse_hostile_input_and_degenerate_pieces() { + let c = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::Y, p3: Vec2::X + Vec2::Y }; + assert!(c.derivatives(0.0, MAX_DERIVATIVE_ORDER + 1).is_none(), "no order above G4"); + assert!(c.derivatives(f64::NAN, 2).is_none()); + let nan = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; + assert!(nan.derivatives(0.0, 1).is_none()); + // A conic whose weight vanishes is a straight chord drawn at zero speed: its derivatives + // are finite, but the first is zero, which is what a blend refuses. + let flat = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 1.0), b: Vec2::new(2.0, 0.0), w: 0.0 }; + let d = flat.derivatives(0.0, 1).unwrap(); + assert_eq!(d[0], Vec2::ZERO); + } +} diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index bba7f847..fff56ea6 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -667,7 +667,7 @@ mod tests { assert!(degree_2(&conic(2.0), Some(vec![1.0, 2.0, 1.0]))); assert!(degree_2(&conic(1.0 - 1e-9), Some(vec![1.0, 1.0 - 1e-9, 1.0]))); // A quintic piece: six control points and twelve knots. - let mut q = [Vec2::ZERO; 8]; + let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, s) in q.iter_mut().enumerate() { *s = Vec2::new(i as f64, (i * i) as f64); } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 334f01f2..b80108a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -864,6 +864,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -948,6 +949,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 324250ba..a4bd1aa1 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1637,7 +1637,7 @@ fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool { } /// [`smooth_chain`], walking on only over edges `keep` allows. -fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { +pub(crate) fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let all = Body::unique_edges(solid); // Along the edge as it runs from its front to its back (an edge may run against its curve). @@ -1859,7 +1859,7 @@ fn fillet_built(body: &Body, edges: &[Vec3], radius: f64) -> Result { // trimmed by the face. Refused, the refusal above stands. .or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)).map_err(|_| e)) // Blends meeting at corners on curved faces: all at once. - .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) + .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet", false).and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) } /// The first refusal, unless only the second found the blend too large (a smaller one may fit). @@ -1872,8 +1872,9 @@ fn keep_size(e: KernelError, e2: KernelError) -> KernelError { } /// Blend the edges together, rebuilding the corners where they meet on faces of any surface -/// (see [`crate::curveblend::corner_blend`]). -fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { +/// (see [`crate::curveblend::corner_blend`]); with `g2`, the corners where three blends meet are +/// G2 patches ([`crate::corner_g2`]) instead of pieces of the ball's sphere. +fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, g2: bool) -> Result { let counts = record::counts(&[body]); let cur = crate::boolean_record::healed(body)?; let size = cur.size(); @@ -1882,10 +1883,27 @@ fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Res if ids.len() != edges.len() { return Err(KernelError::Invalid("no edge near a selected point".into())); } - let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?; + let (made, rec) = guard(what, || match g2 { + // The corner blend as it always was, bit for bit. + false => crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round), + true => crate::curveblend::corner_blend_style(&solid, &ids, size, r, shape == Shape::Round, true), + })?; Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts)) } +/// [`fillet`] whose corners where three blended edges meet are G2 patches: the corner repeats each +/// blend's surface up to the second order (see [`crate::corner_g2`]) instead of the rolling ball's +/// sphere. The corner construction is the all-at-once corner blend, so a configuration it cannot +/// hold is refused rather than quietly given the ball's sphere; [`fillet`] (the default) is +/// unchanged. +pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64) -> Result { + body.require_brep("fillet")?; + if !(radius.is_finite() && radius > 0.0) { + return Err(KernelError::Invalid("the blend radius must be finite and positive".into())); + } + corners(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)) +} + /// The step of the radius search: three significant digits of the requested radius. fn radius_step(radius: f64) -> f64 { 10f64.powf(radius.log10().floor() - 3.0) @@ -2191,7 +2209,7 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result { Ok(cur) }; // Chamfers meeting at corners on curved faces: all at once. - one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2))) + one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer", false).map_err(|e2| keep_size(e, e2))) } /// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left). diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index e0d7a495..8aafc19d 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -205,19 +205,35 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & } /// Fillet one straight edge with the radius `spline` along it, its positions from the end -/// nearest `start`. One end's radius may be zero. +/// nearest `start`. One end's radius may be zero. A curved edge, or one between curved faces, is +/// rolled by a ball of the law's radius instead ([`curved_variable`]). pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { body.require_brep("fillet")?; spline.check()?; let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; guard("variable fillet", || { - let s = EdgeSetup::new(body, &edge)?; + let Ok(s) = EdgeSetup::new(body, &edge) else { + return curved_variable(body, &edge, start, spline); + }; // Positions from the end nearest `start` (the edge keeps its own direction). let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() }; build(body, &s, &edge, &law) }) } +/// The radius law along a curved edge: a ball whose radius is the law's at each station rolls +/// along the edge's smooth chain (the same sections, contacts and trims as the straight-edge case, +/// sized by the law there). A law that has run to zero at an end closes the blend to the edge's +/// end point, as the straight-edge fillet does. +fn curved_variable(body: &Body, edge: &mt::Edge, start: Vec3, law: &RadiusSpline) -> Result { + // Positions from the end nearest `start` (the chain runs along the edge's own direction). + let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back())); + let law = if p1.dist(start) < p0.dist(start) { law.reversed() } else { law.clone() }; + let chain = crate::blend::smooth_chain(&body.solid, edge).unwrap_or_else(|| vec![edge.id()]); + let (made, rec) = guard("variable fillet", || crate::curveblend::curve_blend_law(&body.solid, &chain, body.size(), &law, true))?; + crate::record::rebuilt(made, &[&body.solid], &rec) +} + /// A straight edge between two planar faces as the variable blend sees it: the shell it is in /// and that shell's faces, the frame at the edge, and its ends (front first). struct EdgeSetup<'a> { @@ -570,8 +586,11 @@ pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Ok((cur, tapers)) } -/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend -/// is `chord` wide across; edges with the same face angle share a radius. +/// Chord-length fillet: each edge rounded so that the blend is `chord` wide across, from contact +/// to contact at every station. A straight edge between planar faces is built exactly, the radius +/// `chord / (2 cos(φ/2))` from its face angle and edges sharing that angle blended together; a +/// curved edge, or one between curved faces (or a chain of selected edges running on into one), +/// is rolled by a ball whose radius gives the same width, which follows the face angle along it. pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { body.require_brep("fillet")?; if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) { @@ -580,19 +599,44 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - // Radius per edge from its face angle. - let mut groups: Vec<(f64, Vec)> = Vec::new(); - for p in edges { - let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; - let shell = - body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?; - let faces: Vec<&mt::Face> = shell.face_iter().collect(); - let fr = edge_frame(&faces, &e)?; - let r = chord_radius(chord, fr.phi); - match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { - Some((_, v)) => v.push(*p), - None => groups.push((r, vec![*p])), + let found: Vec = edges + .iter() + .map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) + .collect::>()?; + // The exact radius of a straight edge between planar faces; None where the rolling ball has to + // take the edge (a curved edge, or one between curved faces). + let exact_radius = |e: &mt::Edge| -> Option { + if !matches!(e.curve(), mt::Curve::Line(_)) { + return None; } + let shell = body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id()))?; + let faces: Vec<&mt::Face> = shell.face_iter().collect(); + Some(chord_radius(chord, edge_frame(&faces, e).ok()?.phi)) + }; + // A straight edge running on smoothly into a selected curved one goes with it: the ball rolls + // along the chain, so blending the parts apart would leave a step at their joint. + let meets = |a: &mt::Edge, b: &mt::Edge| [a.front(), a.back()].into_iter().any(|v| v == b.front() || v == b.back()); + let mixed = |e: &mt::Edge| found.iter().any(|f| f.id() != e.id() && exact_radius(f).is_none() && meets(e, f)); + // Radius per edge from its face angle; the rest roll a ball along their chain. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + let mut rolled: Vec> = Vec::new(); + let mut done: Vec = Vec::new(); + for (p, e) in edges.iter().zip(&found) { + if let Some(r) = exact_radius(e).filter(|_| !mixed(e)) { + match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { + Some((_, v)) => v.push(*p), + None => groups.push((r, vec![*p])), + } + continue; + } + if done.contains(&e.id()) { + continue; + } + // The whole smooth chain (as the constant-radius path rolls it): a ball cannot stop + // where the edge runs on, so the chain's other edges are blended with it. + let chain = crate::blend::smooth_chain(&body.solid, e).unwrap_or_else(|| vec![e.id()]); + done.extend(chain.iter().copied()); + rolled.push(edges.iter().zip(&found).filter(|(_, f)| chain.contains(&f.id())).map(|(p, _)| *p).collect()); } // Each step's result carries its history over `[body]`. let counts = crate::record::counts(&[body]); @@ -600,6 +644,17 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { for (r, pts) in groups { cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts); } + for picks in rolled { + // The chain as the current body has it: the blends before it rebuilt the faces it runs + // through, and a rebuilt face carries its edges again. + let edges: Vec = picks.iter().filter_map(|p| crate::surfaces::edge_near(&cur, *p)).collect(); + let Some(first) = edges.first() else { continue }; + let keep = |x: &mt::Edge| edges.iter().any(|e| e.id() == x.id()); + let chain = crate::blend::smooth_chain_within(&cur.solid, first, &keep).unwrap_or_else(|| vec![first.id()]); + let (made, rec) = crate::guard("chord fillet", || crate::curveblend::curve_blend_chord(&cur.solid, &chain, cur.size(), chord, true))?; + let step = crate::record::rebuilt(made, &[&cur.solid], &rec)?; + cur = crate::record::after(step, &cur, &counts); + } Ok(cur) } diff --git a/crates/kernel/src/continuity.rs b/crates/kernel/src/continuity.rs new file mode 100644 index 00000000..001e3289 --- /dev/null +++ b/crates/kernel/src/continuity.rs @@ -0,0 +1,497 @@ +//! Continuity diagnostics across the seams between a body's faces: do the two faces that meet +//! at an edge share a curve (G0), a tangent plane (G1) and a curvature (G2)? +//! +//! The stations are taken on the edge itself — the curve both faces are supposed to share — so +//! the numbers describe the B-rep and not a tessellation: each station is projected onto each +//! face's own surface, and the two projections are compared. The gap is the distance between +//! them (G0); the normal angle is the angle between the faces' outward normals there (G1); the +//! curvature jump is the difference of their normal curvatures in the normal section square to +//! the edge, from the second fundamental form of each surface (G2). A rolling-ball fillet is +//! tangent to the faces it joins but is not curvature continuous: where it meets a plane its +//! seam reads 0° and 1 / radius, which no chord tolerance would reveal. +//! +//! [`crate::smooth_edges`] decides whether a join needs a *display* line; this module measures +//! the join, and backs the engine's `inspect.continuity`. + +use serde::Serialize; +use solidcraft_geom::Vec3; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3}; +use crate::{KernelError, Result}; + +/// Bodies with more edges than this are refused rather than sampled for minutes. +const MAX_EDGES: usize = 4096; +/// Most faces a body may have and still be analysed. +const MAX_FACES: usize = 50_000; +/// Stations one call may take over all its edges; edges past the budget are left out and +/// counted in [`ContinuityReport::skipped`]. +const MAX_STATIONS: usize = 200_000; +/// Most edges one pick list may name (the full list always fits the station budget). +const MAX_PICKS: usize = 512; +/// Samples used to find the edge nearest a picked point (as in `surfaces::edge_near`). +const PICK_SAMPLES: usize = 32; + +/// Least and most stations along one edge (`inspect.continuity`'s `samples`): three are the two +/// ends and the middle. +pub const MIN_SAMPLES: usize = 3; +pub const MAX_SAMPLES: usize = 257; + +/// How an edge is picked: a point on (or near) it, or its index in the display mesh's edge +/// order ([`Body::edges`]), which is what a viewport pick carries. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgePick { + Point(Vec3), + Index(usize), +} + +/// What counts as continuous across a seam. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContinuityTolerances { + /// Largest position gap that still counts as coincident (G0), mm. + pub gap_mm: f64, + /// Largest angle between the faces' normals that still counts as tangent (G1), degrees. + pub angle_deg: f64, + /// Largest curvature jump that still counts as curvature continuous (G2), 1/mm. + pub curvature_per_mm: f64, +} + +/// The worst value of one metric along an edge, with where it was found. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct SeamWorst { + pub value: f64, + /// The station that gave the worst value, in the body's coordinates. + pub at: Vec3, + /// How far along the edge it is: 0 at the edge's start, 1 at its end. + pub edge_fraction: f64, +} + +/// Which test a seam fails, from the best continuity to the worst: [`SeamVerdict::Smooth`] is +/// within every tolerance (G2 or better), then a tangent seam whose curvature jumps (G1), then +/// a visible crease (G0 only), and finally faces that do not meet at all. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SeamVerdict { + Smooth, + CurvatureJump, + Sharp, + Gap, +} + +/// One edge's diagnostics: the worst of each metric along it, and where. +#[derive(Clone, Debug, Serialize)] +pub struct SeamReport { + /// Index in the display mesh's edge order ([`Body::edges`]). + pub edge: usize, + /// Length of the edge, mm. + pub length: f64, + /// Stations the values were taken at. + pub samples: usize, + pub gap_mm: SeamWorst, + pub normal_angle_deg: SeamWorst, + pub curvature_jump_per_mm: SeamWorst, + /// What the tolerances make of the seam. + pub verdict: SeamVerdict, + /// The stations in order: what a viewport shades. + pub points: Vec, +} + +/// The seam diagnostics of one body. +#[derive(Clone, Debug, Serialize)] +pub struct ContinuityReport { + pub seams: Vec, + /// Edges left out: not a seam between two faces (an open boundary, or a periodic face's own + /// seam, which is one face meeting itself), or past the station budget. + pub skipped: usize, + /// Edges that could not be sampled at all: no station of theirs projected onto both faces. + pub unmeasured: usize, +} + +/// The edge indices `picks` mean, in display-mesh order and without repeats. A point pick takes +/// the edge nearest it (within the distance the kernel re-finds an edge from); an index pick +/// must be an existing index. An empty list means every two-sided seam of the body, and a pick +/// of an edge that is not a seam between two faces is refused, not guessed at. +pub fn pick_edges(body: &Body, picks: &[EdgePick]) -> Result> { + body.require_brep("continuity")?; + if picks.len() > MAX_PICKS { + return Err(KernelError::Invalid(format!("pick at most {MAX_PICKS} edges"))); + } + crate::guard("continuity picks", || { + let (edges, adjacent) = topology(body)?; + if picks.is_empty() { + return Ok((0..edges.len()).filter(|i| two_sided(&adjacent, *i)).collect()); + } + let reach = body.size() * 0.05 + 1e-6; + let mut out: Vec = Vec::new(); + for pick in picks { + let index = match *pick { + EdgePick::Index(index) => index, + EdgePick::Point(p) => nearest_edge(&edges, p, reach)?, + }; + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + if !out.contains(&index) { + out.push(index); + } + } + out.sort_unstable(); + Ok(out) + }) +} + +/// Sample the two-sided seams `edges` of `body` (indices in display-mesh order; empty: every +/// one of them) at `samples` stations along each edge's own parameter, the ends included, with +/// the tolerances the seams are judged against. +pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + body.require_brep("continuity")?; + if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) { + return Err(KernelError::Invalid(format!("`samples` must be {MIN_SAMPLES}…{MAX_SAMPLES}"))); + } + for (name, value) in [("gap", tolerances.gap_mm), ("angle", tolerances.angle_deg), ("curvature", tolerances.curvature_per_mm)] { + // A range test also throws out NaN (both comparisons are false). + if !(0.0..1e9).contains(&value) { + return Err(KernelError::Invalid(format!("the {name} tolerance must be a finite number, 0 or more"))); + } + } + crate::guard("continuity", || measure(body, edges, samples, tolerances)) +} + +/// The body's unique edges and the faces meeting at each of them, either under the caps. +fn topology(body: &Body) -> Result<(Vec, Vec>)> { + if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_FACES} faces"))); + } + let (edges, adjacent) = crate::body::edge_order(body.solid.face_iter()); + if edges.len() > MAX_EDGES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_EDGES} edges"))); + } + Ok((edges, adjacent)) +} + +/// Is the edge at `index` shared by (exactly) two faces of the body? +fn two_sided(adjacent: &[Vec], index: usize) -> bool { + adjacent.get(index).is_some_and(|faces| faces.len() == 2) +} + +/// The index of the edge whose curve comes nearest `p`, within `reach`. +fn nearest_edge(edges: &[mt::Edge], p: Vec3, reach: f64) -> Result { + use mt::ParametricCurve; + let mut best: Option<(f64, usize)> = None; + for (index, edge) in edges.iter().enumerate() { + let curve = edge.curve(); + let Some((t0, t1)) = curve.try_range_tuple() else { continue }; + if !(t0.is_finite() && t1.is_finite() && t1 > t0) { + continue; + } + for k in 0..=PICK_SAMPLES { + let q = from_p3(curve.subs(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64)); + let d = q.dist(p); + if d.is_finite() && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, index)); + } + } + } + match best { + Some((d, index)) if d <= reach => Ok(index), + _ => Err(KernelError::Invalid(format!("no edge of the body is within {reach:.3} mm of ({}, {}, {})", p.x, p.y, p.z))), + } +} + +fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + let (list, adjacent) = topology(body)?; + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + let mut selected: Vec = Vec::new(); + let mut skipped = 0usize; + if edges.is_empty() { + for index in 0..list.len() { + if two_sided(&adjacent, index) { + selected.push(index); + } else { + skipped += 1; + } + } + } else { + selected.extend(edges.iter().copied()); + selected.sort_unstable(); + selected.dedup(); + for &index in &selected { + if index >= list.len() { + return Err(KernelError::Invalid(format!("the body has no edge {index}"))); + } + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + } + } + let mut seams = Vec::new(); + let mut unmeasured = 0usize; + let mut budget = MAX_STATIONS; + for index in selected { + let Some(face_pair) = adjacent.get(index).filter(|faces| faces.len() == 2) else { continue }; + let (Some(&first), Some(&second)) = (face_pair.first(), face_pair.get(1)) else { continue }; + let (Some(a), Some(b)) = (faces.get(first).copied(), faces.get(second).copied()) else { continue }; + let Some(edge) = list.get(index) else { continue }; + // Every edge costs `samples` stations; a run out of budget stops cleanly. + if budget < samples { + skipped += 1; + continue; + } + budget -= samples; + match seam(index, edge, a, b, samples, tolerances) { + Some(report) => seams.push(report), + None => unmeasured += 1, + } + } + Ok(ContinuityReport { seams, skipped, unmeasured }) +} + +/// One edge's worst gap, normal angle and curvature jump. +fn seam(index: usize, edge: &mt::Edge, face_a: &mt::Face, face_b: &mt::Face, samples: usize, tolerances: ContinuityTolerances) -> Option { + use mt::ParametricCurve; + let curve = edge.curve(); + let (t0, t1) = curve.try_range_tuple()?; + // The station fractions: 0 at the edge's start, 1 at its end. + let steps = samples.checked_sub(1)?; + let fraction = |k: usize| k as f64 / steps as f64; + let mut on_a = FaceSurface::new(face_a); + let mut on_b = FaceSurface::new(face_b); + let mut gap = None; + let mut angle = None; + let mut jump = None; + let mut points = Vec::with_capacity(samples); + for k in 0..samples { + let f = fraction(k); + let at = from_p3(curve.subs(t0 + (t1 - t0) * f)); + if !at.is_finite() { + continue; + } + // Both projections and both curvatures, or the station says nothing. + let (Some((qa, na, uv_a)), Some((qb, nb, uv_b))) = (on_a.project(at), on_b.project(at)) else { continue }; + let tangent = curve.der(t0 + (t1 - t0) * f); + let Some(tangent) = Vec3::new(tangent.x, tangent.y, tangent.z).normalized() else { continue }; + // The normal sections square to the seam: one direction per face, in its tangent plane. + let (Some(ka), Some(kb)) = (on_a.normal_curvature(uv_a, na, across(na, tangent)), on_b.normal_curvature(uv_b, nb, across(nb, tangent))) + else { + continue; + }; + // The faces' outward normals agree on a smooth seam; where they are opposed (a knife + // edge) the curvatures are compared in one frame, since bending away from one face is + // bending towards the other. + let signed = if na.dot(nb) < 0.0 { ka + kb } else { ka - kb }; + points.push(at); + keep(&mut gap, qa.dist(qb), at, f); + keep(&mut angle, na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), at, f); + keep(&mut jump, signed.abs(), at, f); + } + let (gap, angle, jump) = (gap?, angle?, jump?); + let verdict = if gap.value > tolerances.gap_mm { + SeamVerdict::Gap + } else if angle.value > tolerances.angle_deg { + SeamVerdict::Sharp + } else if jump.value > tolerances.curvature_per_mm { + SeamVerdict::CurvatureJump + } else { + SeamVerdict::Smooth + }; + let length = points.windows(2).map(|w| w.first().zip(w.get(1)).map_or(0.0, |(a, b)| a.dist(*b))).sum(); + Some(SeamReport { + edge: index, + length, + samples: points.len(), + gap_mm: gap, + normal_angle_deg: angle, + curvature_jump_per_mm: jump, + verdict, + points, + }) +} + +/// The direction in which a face's curvature is read at a seam: across the edge, square to it, +/// inside the face's tangent plane (a zero vector where the edge runs along the normal, which +/// leaves that station unmeasured). +fn across(normal: Vec3, tangent: Vec3) -> Vec3 { + normal.cross(tangent) +} + +/// Keep the largest value seen, with where it was found. +fn keep(worst: &mut Option, value: f64, at: Vec3, edge_fraction: f64) { + if value.is_finite() && worst.is_none_or(|w| value > w.value) { + *worst = Some(SeamWorst { value, at, edge_fraction }); + } +} + +/// A face's surface, kept over the stations of one edge so each search can start at the last +/// answer (stations run along the edge, so the parameters move smoothly). +struct FaceSurface { + surface: mt::Surface, + hint: Option<(f64, f64)>, +} + +impl FaceSurface { + /// The face's own outward-facing surface (`oriented_surface`: a reversed face's surface is + /// inverted, so its normal is the material's outward normal). + fn new(face: &mt::Face) -> Self { + Self { surface: face.oriented_surface(), hint: None } + } + + /// The nearest point of the surface to `p`, the outward unit normal there and the + /// parameters. `None` where the search or the normal is unusable. + fn project(&mut self, p: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let uv = self.surface.search_nearest_parameter(p3(p), self.hint, 100).or_else(|| self.surface.search_nearest_parameter(p3(p), None, 100))?; + if !(uv.0.is_finite() && uv.1.is_finite()) { + return None; + } + let q = from_p3(self.surface.subs(uv.0, uv.1)); + let n = self.surface.normal(uv.0, uv.1); + if !q.is_finite() { + return None; + } + let normal = Vec3::new(n.x, n.y, n.z).normalized()?; + self.hint = Some(uv); + Some((q, normal, uv)) + } + + /// The normal curvature of the face at `uv` along `direction` (a unit vector of its tangent + /// plane), from the second fundamental form. A surface point `r(u, v)` with unit normal `n` + /// crossed that way has curvature `κ = n · r''` of the curve whose tangent there is + /// `direction`: a plane reads 0 and a cylinder of radius `R`, across its axis, −1 / R + /// (bending towards its axis). `None` at a degenerate parameter point (a cone's apex), where + /// the surface has no curvature. + fn normal_curvature(&self, uv: (f64, f64), normal: Vec3, direction: Vec3) -> Option { + use mt::ParametricSurface; + let ru = vec3(self.surface.uder(uv.0, uv.1)); + let rv = vec3(self.surface.vder(uv.0, uv.1)); + let (ru_len, rv_len) = (ru.len(), rv.len()); + if !(ru_len > 0.0 && rv_len > 0.0) { + return None; + } + // Solve the direction in the unit basis (r_u / |r_u|, r_v / |r_v|): the curve then runs + // its parameters at du/ds = α / |r_u| and dv/ds = β / |r_v|, and + // `r'' = r_uu (du/ds)² + 2 r_uv (du/ds)(dv/ds) + r_vv (dv/ds)²`. + let cos = (ru.dot(rv) / (ru_len * rv_len)).clamp(-1.0, 1.0); + let sin2 = 1.0 - cos * cos; + if !(sin2 > 1e-12) { + return None; + } + let (cu, cv) = (direction.dot(ru) / ru_len, direction.dot(rv) / rv_len); + let (du, dv) = ((cu - cv * cos) / sin2 / ru_len, (cv - cu * cos) / sin2 / rv_len); + if !(du.is_finite() && dv.is_finite()) { + return None; + } + // `direction` is a unit vector: divide by the speed the parameters imply (they only miss + // it by rounding), so κ does not depend on how the surface is parameterized. + let speed = (ru * du + rv * dv).len(); + if !(speed > 1e-6) { + return None; + } + let second = normal.dot( + vec3(self.surface.uuder(uv.0, uv.1)) * (du * du) + + vec3(self.surface.uvder(uv.0, uv.1)) * (2.0 * du * dv) + + vec3(self.surface.vvder(uv.0, uv.1)) * (dv * dv), + ); + (second / (speed * speed)).is_finite().then_some(second / (speed * speed)) + } +} + +/// A kernel point as a vector. +fn vec3(p: mt::Vector3) -> Vec3 { + Vec3::new(p.x, p.y, p.z) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use solidcraft_geom::Plane; + + fn tolerances() -> ContinuityTolerances { + ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 } + } + + fn box_body() -> Body { + crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap() + } + + fn report(body: &Body) -> ContinuityReport { + seam_diagnostics(body, &[], 33, tolerances()).unwrap() + } + + #[test] + fn a_box_edge_is_a_gap_free_right_angle() { + let body = box_body(); + let r = report(&body); + assert_eq!(r.seams.len(), 12, "a box has twelve edges"); + assert_eq!(r.skipped, 0); + for seam in &r.seams { + assert!(seam.gap_mm.value < 1e-6, "gap {}", seam.gap_mm.value); + assert!((seam.normal_angle_deg.value - 90.0).abs() < 1e-9, "angle {}", seam.normal_angle_deg.value); + assert!(seam.curvature_jump_per_mm.value < 1e-9, "jump {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::Sharp); + assert_eq!(seam.samples, 33); + assert!((seam.length - 10.0).abs() < 1e-9); + } + } + + #[test] + fn a_fillet_seam_is_tangent_with_the_fillets_curvature_jump() { + let body = box_body(); + let rounded = crate::fillet(&body, &[Vec3::new(10.0, 10.0, 5.0)], 4.0).unwrap(); + let r = report(&rounded); + // The rounded edge is replaced by the blend face: two tangent seams along it, the two + // arcs where the blend meets the ends of the edge, and the box's other eleven edges. + assert_eq!(r.seams.len(), 15); + let tangent: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(tangent.len(), 2, "the fillet meets its two neighbours tangentially"); + for seam in &tangent { + assert!(seam.gap_mm.value < 1e-6); + assert!(seam.curvature_jump_per_mm.value.is_finite()); + assert!((seam.curvature_jump_per_mm.value - 0.25).abs() < 1e-6, "1 / radius, got {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::CurvatureJump); + } + assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13); + } + + #[test] + fn a_line_tangent_into_an_arc_reports_one_over_its_radius() { + use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; + let v = |x: f64, y: f64| Vec2::new(x, y); + let outer = Loop2 { + segs: vec![ + Seg2::Arc { center: v(10.0, 0.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(10.0, 5.0), b: v(-10.0, 5.0) }, + Seg2::Arc { center: v(-10.0, 0.0), radius: 5.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(-10.0, -5.0), b: v(10.0, -5.0) }, + ], + }; + let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0); + let r = report(&body); + let joins: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(joins.len(), 4, "a slot has four line-to-arc joins"); + for seam in joins { + assert!( + seam.curvature_jump_per_mm.value > 0.19 && seam.curvature_jump_per_mm.value < 0.21, + "1 / 5, got {}", + seam.curvature_jump_per_mm.value + ); + } + } + + #[test] + fn picks_refuse_what_is_not_there() { + let body = box_body(); + let all = pick_edges(&body, &[]).unwrap(); + assert_eq!(all.len(), 12); + // The edge up the corner at (0, 0), beside a point in the middle of a face. + let picked = pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 0.0, 5.0)), EdgePick::Index(all[0])]).unwrap(); + assert_eq!(picked.len(), 2); + assert!(pick_edges(&body, &[EdgePick::Index(999)]).is_err()); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 5.0, 5.0))]).is_err(), "a point on a face is not on an edge"); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(500.0, 0.0, 0.0))]).is_err()); + assert!(seam_diagnostics(&body, &[], 2, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 258, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: f64::NAN, ..tolerances() }).is_err()); + assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err()); + } +} diff --git a/crates/kernel/src/corner_g2.rs b/crates/kernel/src/corner_g2.rs new file mode 100644 index 00000000..22e236df --- /dev/null +++ b/crates/kernel/src/corner_g2.rs @@ -0,0 +1,501 @@ +//! G2 corner patches: the patch that fills a vertex where filleted edges meet, built to repeat each +//! blend's surface up to the second order instead of the rolling ball's sphere. +//! +//! [`crate::blend_corner`] and [`crate::curvecorner`] fill the corner of a vertex with a piece of +//! the ball that rolls along the three edges (a sphere for equal radii, a rational patch for +//! unequal ones). That patch is tangent to every blend along the sections where they stop, so the +//! corner is G1 — but across a seam the ball keeps its own 1/r where a fillet is flat along its +//! axis, so the two surfaces meet with a jump in curvature. +//! +//! Here the corner is one tensor patch whose boundary is the blends' section curves themselves and +//! whose cross-sections carry each blend's own derivatives: the position, the direction the patch +//! leaves the blend's surface in, and the blend's curvature vector along that direction are the +//! first three control points of every cross-section, one control point per continuity condition, +//! exactly as [`crate::bridge`]'s sections do. The other end of every cross-section gathers at +//! `apex` — a point inside the material the corner caps — where the sections arrive in a common +//! tangent plane with a common curvature, so the patch closes there smoothly like a sphere's pole. +//! Along a seam the patch therefore repeats the blend up to the second order (G2). +//! +//! The boundary is a closed loop (the three sections joined corner to corner), so the direction +//! along it is a *closed* cubic B-spline: an interpolating curve with free ends swings wide at the +//! corner where the loop joins and folds the patch there. Where two blends meet at that corner +//! their sections leave in directions 90° apart and ask for different curvature at that one point +//! (a fillet is flat across its own seam, the other is not), which no single surface can hold; the +//! corner is a still point of the construction and [`seam_error`] is measured clear of it. A patch +//! that folds, or a corner with no room, is refused rather than replaced by the G1 ball. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{HPoint, NurbsSurface}; +use truck_modeling::{self as mt, builder}; + +use crate::blend::unsupported; +use crate::body::{from_p3, p3}; +use crate::bridge::Frame; +use crate::{KernelError, Result, guard}; + +/// A corner's name in an error. +fn fail(m: impl std::fmt::Display) -> KernelError { + KernelError::Failed(format!("G2 corner: {m}")) +} + +/// One of the surface's own derivative vectors as ours. +fn vec_of(v: mt::Vector3) -> Vec3 { + Vec3::new(v.x, v.y, v.z) +} + +/// Conditions each end of a cross-section carries: position, tangent and curvature. +const END_CONDITIONS: usize = 3; +/// Degree across the patch: three conditions at each end, less the control point they share. +const ACROSS: usize = 2 * END_CONDITIONS - 1; +/// Degree along the seam loop. +const ALONG: usize = 3; +/// Fewest stations a section curve may be sampled at (a cubic through them). +const MIN_STATIONS: usize = 4; +/// Most stations one section curve may carry. +const MAX_STATIONS: usize = 512; +/// How much of a station's `leave` has to lie across its seam for the patch to have a direction to +/// leave the blend in at all. +const ACROSS_ENOUGH: f64 = 0.2; + +/// The G2 corner patch over three blends' section curves: `sides[i]` is one blend's section curve, +/// sampled in order from one corner of the vertex to the next, each side's last station the next +/// side's first (the three close a loop). `apex` is a point inside the material the corner caps; +/// the patch gathers there. The face's boundary edges are the section curves themselves (one edge +/// per side, cut out of the patch's own boundary curve, so they lie on it exactly); the caller +/// re-wraps the wire where it needs its own orientation. +pub fn corner_g2_patch(sides: &[Vec], apex: Vec3) -> Result> { + guard("G2 corner patch", || build(sides, apex)) +} + +/// The seam's own direction at station `i` of a side, from a four-point central difference through +/// the stations (`crate::bridge::frames` samples a chain evenly, and a corner's own callers do the +/// same): a two-point chord would only be second order in the step and would tilt the corner's +/// tangent plane by more than G2 is judged at. `None` within two stations of a side's end. +fn seam_tangent(side: &[Frame], i: usize) -> Option { + let at = |j: isize| usize::try_from(j).ok().and_then(|j| side.get(j)).map(|f| f.p); + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() +} + +/// The seam's direction at every station of the closed loop (the same difference, wrapping round +/// its ends), falling back to the chord to a neighbour where the four points are not there. +fn loop_tangents(frames: &[Frame]) -> Vec> { + let m = frames.len(); + if m == 0 { + return Vec::new(); + } + let at = |j: isize| frames.get(j.rem_euclid(m as isize) as usize).map(|f| f.p); + (0..m) + .map(|i| { + let four = || { + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() + }; + four().or_else(|| { + let prev = at(i as isize - 1)?; + let next = at(i as isize + 1)?; + (next - prev).normalized() + }) + }) + .collect() +} + +/// The three section curves as one closed loop of stations, the loop index each side starts at, +/// and the corner's own size (twice the mean distance of a station from their centre). +fn corner_loop(sides: &[Vec]) -> Result<(Vec, Vec, f64)> { + if sides.len() != 3 { + return Err(unsupported("a G2 corner needs three blends meeting at one vertex (a corner of two, or one, still ends on the ball's patch)")); + } + let mut frames: Vec = Vec::new(); + let mut starts: Vec = Vec::new(); + for side in sides { + if side.len() < MIN_STATIONS || side.len() > MAX_STATIONS { + return Err(KernelError::Invalid(format!("a corner section curve needs {MIN_STATIONS} to {MAX_STATIONS} sampled stations"))); + } + for f in side { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) || !(f.leave.len() > 0.5) { + return Err(KernelError::Invalid("a corner station must carry finite data and a unit direction".into())); + } + } + starts.push(frames.len()); + // A side runs from one corner to the next; its last station is the next side's first, so the + // loop takes it once. + frames.extend(side.iter().take(side.len() - 1).copied()); + } + let centre = frames.iter().fold(Vec3::ZERO, |a, f| a + f.p) * (1.0 / frames.len().max(1) as f64); + let size = frames.iter().map(|f| f.p.dist(centre)).fold(0.0, f64::max) * 2.0; + if !(size > 0.0 && size.is_finite()) { + return Err(unsupported("a corner whose section curves have no extent")); + } + for (i, side) in sides.iter().enumerate() { + let Some(prev) = sides.get((i + sides.len() - 1) % sides.len()).and_then(|s| s.last()) else { continue }; + let Some(&here) = side.first() else { continue }; + if prev.p.dist(here.p) > size * 1e-3 { + return Err(unsupported("the section curves do not meet at a corner")); + } + } + Ok((frames, starts, size)) +} + +/// Every station's `leave` squared up with the seam it belongs to: the part of it across the seam, +/// which is the direction the cross-section has to leave the blend in. A `leave` along the seam +/// itself leaves the cross-section no room, and is refused. +fn transverse(frames: &[Frame], tangents: &[Option]) -> Result> { + let mut out = Vec::with_capacity(frames.len()); + for (f, t) in frames.iter().zip(tangents) { + let across = match t { + Some(t) => f.leave - *t * f.leave.dot(*t), + // Stations that offer no direction along the seam: the leave stands as it came. + None => f.leave, + }; + if across.len() < ACROSS_ENOUGH * f.leave.len() { + return Err(unsupported("a section curve meets the corner patch along its own tangent, so the patch has no direction to leave it in")); + } + let leave = across.normalized().ok_or_else(|| unsupported("a corner station has no direction across its seam"))?; + out.push(Frame { leave, ..*f }); + } + Ok(out) +} + +/// One station's cross-section: `ACROSS + 1` control points of a Bézier from the seam point to +/// `apex`. The first three hold the blend: its position, the direction the patch leaves it in +/// (one control point along `leave`) and its curvature vector across the seam (one more). The last +/// three hold the apex: the point, a tangent in the apex's own plane, and the curvature of a dome +/// over the corner, so the sections arrive smoothly instead of leaving a point. +fn section(f: &Frame, apex: Vec3, n_apex: Vec3, kappa: f64) -> Result<[Vec3; ACROSS + 1]> { + let k = ACROSS as f64; + let chord = f.p.dist(apex); + if !(chord.is_finite() && chord > 0.0) { + return Err(unsupported("a blend too small for the corner patch to fit")); + } + let speed = chord / 3.0; + let to_apex = (f.p - apex).normalized().ok_or_else(|| unsupported("a section curve starts on the apex"))?; + let leave_apex = + (to_apex - n_apex * to_apex.dot(n_apex)).normalized().ok_or_else(|| unsupported("a section curve reaches the apex along the apex normal"))?; + let mut c = [Vec3::ZERO; ACROSS + 1]; + c[0] = f.p; + c[1] = f.p + f.leave * (speed / k); + c[2] = f.bend * (speed * speed / (k * (k - 1.0))) + c[1] * 2.0 - c[0]; + c[ACROSS] = apex; + c[ACROSS - 1] = apex + leave_apex * (speed / k); + c[ACROSS - 2] = (-n_apex * kappa) * (speed * speed / (k * (k - 1.0))) + c[ACROSS - 1] * 2.0 - c[ACROSS]; + if c.iter().any(|p| !p.is_finite()) { + return Err(fail("a cross-section of the corner is not finite")); + } + Ok(c) +} + +/// A closed uniform cubic B-spline through `points`, once round the loop: the curve at each station +/// is `(q[i-1] + 4 q[i] + q[i+1]) / 6`, so the stations fix the net by a cyclic tridiagonal system, +/// solved here with the Sherman–Morrison form of the Thomas algorithm (a corner's loop runs to +/// hundreds of stations). The net comes back in the open form the tensor surface wants — the first +/// three control points repeated past the end — so it still closes, with one uniform span per +/// station and no free end anywhere. +fn closed_layer(points: &[Vec3]) -> Result> { + let m = points.len(); + if m < 2 * ALONG + 2 { + return Err(fail("a corner loop needs at least eight stations")); + } + // Sub-diagonal 1, diagonal 4, super-diagonal 1, and 1 in both corners. + let (sub, diag, sup, corner) = (1.0f64, 4.0f64, 1.0f64, 1.0f64); + let gamma = -diag; + let mut bb = vec![diag; m]; + if let Some(first) = bb.first_mut() { + *first = diag - gamma; + } + if let Some(last) = bb.last_mut() { + *last = diag - corner * corner / gamma; + } + // Thomas, one right-hand side at a time (three components solved together). + let thomas = |rhs: &[Vec3]| -> Result> { + let mut c = vec![0.0f64; m]; + let mut d = rhs.to_vec(); + let mut denom = bb.first().copied().unwrap_or(0.0); + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + let (Some(c0), Some(d0)) = (c.first_mut(), d.first_mut()) else { return Err(fail("stations")) }; + *c0 = sup / denom; + *d0 = *d0 * (1.0 / denom); + for i in 1..m { + let (prev_c, prev_d) = (c.get(i - 1).copied().unwrap_or(0.0), d.get(i - 1).copied().unwrap_or(Vec3::ZERO)); + let b = bb.get(i).copied().unwrap_or(diag); + denom = b - sub * prev_c; + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + if let Some(slot) = c.get_mut(i) { + *slot = if i + 1 < m { sup / denom } else { 0.0 }; + } + if let Some(slot) = d.get_mut(i) { + *slot = (*slot - prev_d * sub) * (1.0 / denom); + } + } + let mut x = vec![Vec3::ZERO; m]; + let last = d.last().copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.last_mut() { + *slot = last; + } + for i in (0..m - 1).rev() { + let (Some(ci), Some(xn)) = (c.get(i).copied(), x.get(i + 1).copied()) else { continue }; + let di = d.get(i).copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.get_mut(i) { + *slot = di - xn * ci; + } + } + Ok(x) + }; + let rhs: Vec = points.iter().map(|p| *p * 6.0).collect(); + let x = thomas(&rhs)?; + // The unit right-hand side the corner terms add: gamma at the first station, the corner at the + // last. + let mut unit = vec![Vec3::ZERO; m]; + if let Some(slot) = unit.first_mut() { + *slot = Vec3::new(gamma, gamma, gamma); + } + if let Some(slot) = unit.last_mut() { + *slot = Vec3::new(corner, corner, corner); + } + let z = thomas(&unit)?; + let (x0, xn) = (x.first().copied().unwrap_or(Vec3::ZERO), x.last().copied().unwrap_or(Vec3::ZERO)); + let (z0, zn) = (z.first().copied().unwrap_or(Vec3::ZERO), z.last().copied().unwrap_or(Vec3::ZERO)); + // z answers a scalar right-hand side, so the correction is one scalar factor per component. + let factor = |x0: f64, xn: f64, z0: f64, zn: f64| (x0 + corner * xn / gamma) / (1.0 + z0 + corner * zn / gamma); + let f = Vec3::new(factor(x0.x, xn.x, z0.x, zn.x), factor(x0.y, xn.y, z0.y, zn.y), factor(x0.z, xn.z, z0.z, zn.z)); + let mut out = Vec::with_capacity(m + ALONG); + for i in 0..m { + let (xi, zi) = (x.get(i).copied().unwrap_or(Vec3::ZERO), z.get(i).copied().unwrap_or(Vec3::ZERO)); + out.push(xi - Vec3::new(f.x * zi.x, f.y * zi.y, f.z * zi.z)); + } + if !out.iter().all(|p| p.is_finite()) { + return Err(fail("the corner's boundary is not finite")); + } + for i in 0..ALONG { + let p = out.get(i).copied().unwrap_or(Vec3::ZERO); + out.push(p); + } + Ok(out) +} + +/// The stations of the loop as one tensor control net: across (v) each station's cross-section is +/// the Bézier above, along the seam loop (u) each of its control points is a closed cubic through +/// the stations, so the face passes through every section exactly and its parameter line along the +/// loop closes without an end to swing from. The loop's station count comes back with it: station +/// `i` sits at parameter `i + ALONG`, and the loop closes at `count + ALONG`. +fn tensor(layers: &[[Vec3; ACROSS + 1]]) -> Result<(NurbsSurface, f64)> { + let m = layers.len(); + let nu = m + ALONG; + let knots_u: Vec = (0..=(nu + ALONG) as i64).map(|i| i as f64).collect(); + let mut ctrl: Vec = Vec::with_capacity(nu * (ACROSS + 1)); + for j in 0..=ACROSS { + let points: Vec = layers.iter().map(|c| c.get(j).copied().unwrap_or(Vec3::ZERO)).collect(); + let net = closed_layer(&points)?; + for p in &net { + ctrl.push(HPoint::weighted(*p, 1.0)); + } + } + let mut knots_v = vec![0.0; ACROSS + 1]; + knots_v.extend(std::iter::repeat_n(1.0, ACROSS + 1)); + let s = NurbsSurface::from_homogeneous([ALONG, ACROSS], [knots_u, knots_v], ctrl).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok((s, m as f64)) +} + +/// The surface across its own domain: the patch must keep facing one way, or a blend would be met +/// by a piece of the patch folded back on itself. +fn no_fold(surface: &mt::Surface, loop_range: (f64, f64)) -> Result<()> { + use mt::ParametricSurface; + // The u knots run past the loop at both ends (a closed curve has no clamped ends to name), so + // the loop's own range is brought in rather than read off the knot vector. + let (u0, u1) = loop_range; + let Some((v0, v1)) = surface.try_range_tuple().1 else { + return Err(fail("the corner patch has no parameter domain")); + }; + let normal = |u: f64, v: f64| -> Option { vec_of(surface.uder(u, v)).cross(vec_of(surface.vder(u, v))).normalized() }; + let mid = normal((u0 + u1) * 0.5, v0 + (v1 - v0) * 0.5).ok_or_else(|| unsupported("the corner patch has no normal at its middle"))?; + // Clear of the apex (v1), where the patch's parameter line along the seam collapses to a point + // and the normal is read from the sections approaching it. + for i in 0..=8 { + for j in 0..9 { + let u = u0 + (u1 - u0) * i as f64 / 8.0; + let v = v0 + (v1 - v0) * j as f64 / 9.0; + let n = normal(u, v).ok_or_else(|| unsupported("the corner patch folds back on itself"))?; + if n.dot(mid) <= 0.0 { + return Err(unsupported("the corner patch folds back on itself with these section curves")); + } + } + } + Ok(()) +} + +/// Build the patch: sections, tensor surface, and the wire of the section curves themselves. +fn build(sides: &[Vec], apex: Vec3) -> Result> { + if !apex.is_finite() { + return Err(KernelError::Invalid("the corner apex must be a finite point".into())); + } + let (frames, starts, size) = corner_loop(sides)?; + let frames = transverse(&frames, &loop_tangents(&frames))?; + // The apex's own plane: the mean direction from the apex to the boundary it caps. + let n_apex = frames + .iter() + .fold(Vec3::ZERO, |a, f| a + (f.p - apex)) + .normalized() + .ok_or_else(|| unsupported("the corner's apex sits on its own boundary curves"))?; + // How hard the patch bends into the apex: as much as its blends do across their seams, and + // never flatter than a dome of the corner's own size (a flat cap would end in a point). + let mean_bend = frames.iter().map(|f| f.bend.len()).sum::() / frames.len().max(1) as f64; + let reach = (frames.iter().map(|f| f.p.dist(apex)).sum::() / frames.len().max(1) as f64).max(size * 1e-3); + let kappa = mean_bend.max(1.0 / (2.0 * reach)); + if !(kappa.is_finite() && kappa > 0.0) { + return Err(unsupported("a corner the patch cannot span")); + } + let layers: Vec<[Vec3; ACROSS + 1]> = frames.iter().map(|f| section(f, apex, n_apex, kappa)).collect::>()?; + let (nurbs, m) = tensor(&layers)?; + let surface = crate::spline_conv::surface(&nurbs)?; + no_fold(&surface, (ALONG as f64, m + ALONG as f64))?; + // The boundary is the loop of section curves; each side is cut out of the patch's own boundary + // curve so that it lies on the patch exactly. + let nu = nurbs.count().first().copied().unwrap_or(0); + let row: Vec = (0..nu).map(|i| nurbs.point(i, 0)).collect(); + let Some(knots_u) = nurbs.knots().first().cloned() else { return Err(fail("knots")) }; + let loop_curve = crate::spline_conv::nurbs_curve(mt::KnotVec::from(knots_u), &row)?; + let mut cuts: Vec = starts.iter().map(|i| *i as f64 + ALONG as f64).collect(); + cuts.push(m + ALONG as f64); + let mut verts: Vec = Vec::with_capacity(sides.len()); + for side in sides { + let first = side.first().ok_or_else(|| fail("a section curve has no stations"))?; + verts.push(builder::vertex(p3(first.p))); + } + let mut edges: Vec = Vec::with_capacity(sides.len()); + for i in 0..sides.len() { + let (Some(&a), Some(&b)) = (cuts.get(i), cuts.get(i + 1)) else { return Err(fail("the corner's seams")) }; + let mut piece = loop_curve.clone(); + let _tail = mt::Cut::cut(&mut piece, b); + let piece = mt::Cut::cut(&mut piece, a); + let (Some(from), Some(to)) = (verts.get(i), verts.get((i + 1) % verts.len())) else { return Err(fail("the corner's vertices")) }; + edges.push(mt::Edge::try_new(from, to, piece).map_err(|e| fail(format!("a corner seam: {e}")))?); + } + let face = mt::Face::try_new(vec![mt::Wire::from(edges)], surface).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok(vec![face]) +} + +/// What the patch achieves along the seams of `sides`: the largest distance from a seam station to +/// the patch, the largest angle (radians) between the patch's normal there and the blend's, and the +/// largest difference between the curvature across the seam and the blend's own, relative to +/// `reference` — the curvature a rolling-ball corner shows across the same seam, which is what G2 +/// removes. Measured on the built surface alone, by the same finite differences of its own samples +/// that `crate::bridge`'s tests read a cross-section's shape with, so it is evidence about the +/// geometry and not about the construction. +pub fn seam_error(surface: &mt::Surface, sides: &[Vec], reference: f64) -> Result<(f64, f64, f64)> { + use mt::{ParametricSurface, SearchNearestParameter}; + if !(reference.is_finite() && reference > 0.0) { + return Err(KernelError::Invalid("a reference curvature must be finite and positive".into())); + } + let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { + return Err(fail("the corner patch has no parameter domain")); + }; + // Samples across the section for a five-point difference, as `bridge`'s tests use. + const INWARDS: usize = 5; + let hv = (v1 - v0).abs() * 1e-3; + let hu = (u1 - u0).abs() * 1e-3; + if !(hv > 0.0 && hu > 0.0) { + return Err(fail("the corner patch has no extent to measure")); + } + let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, 0.0f64); + for side in sides { + if side.len() < 5 { + return Err(KernelError::Invalid("a seam needs at least five stations to measure its direction".into())); + } + let mut hint: Option<(f64, f64)> = None; + for (i, f) in side.iter().enumerate() { + let Some(t) = seam_tangent(side, i) else { continue }; + let found = surface + .search_nearest_parameter(p3(f.p), hint, 100) + .or_else(|| surface.search_nearest_parameter(p3(f.p), None, 200)) + .ok_or_else(|| fail("a seam station is not on the corner patch"))?; + hint = Some(found); + let (u, v) = found; + let at = from_p3(surface.subs(u, v)); + gap = gap.max(at.dist(f.p)); + // The cross-section, sampled inwards from the seam (or from the other end of the patch, + // with the odd differences turned round). + let out = v <= (v0 + v1) * 0.5; + let step = if out { hv } else { -hv }; + let q: Vec = (0..INWARDS).map(|k| from_p3(surface.subs(u, v + step * k as f64))).collect(); + let (Some(&f0), Some(&f1), Some(&f2), Some(&f3), Some(&f4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else { + return Err(fail("the corner patch has no room for a sample")); + }; + let sign = if out { 1.0 } else { -1.0 }; + let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (sign / (12.0 * hv)); + let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * hv * hv)); + // Along the seam, to stand the tangent plane up. + let du = if u - hu >= u0 && u + hu <= u1 { + (from_p3(surface.subs(u + hu, v)) - from_p3(surface.subs(u - hu, v))) * (0.5 / hu) + } else { + let (a, b) = if u + hu <= u1 { (u, u + hu) } else { (u, u - hu) }; + (from_p3(surface.subs(b, v)) - from_p3(surface.subs(a, v))) * (1.0 / hu) + }; + let n = du.cross(d1).normalized().ok_or_else(|| fail("the corner patch has no normal at a seam"))?; + let blend_n = t.cross(f.leave).normalized().ok_or_else(|| fail("a blend has no normal at a seam"))?; + angle = angle.max(n.dot(blend_n).abs().clamp(0.0, 1.0).acos()); + // The curvature vector of the cross-seam curve: what the blend's own surface does along + // the direction the patch leaves it in. + let speed2 = d1.len2().max(1e-300); + let bend = (d2 - d1 * (d2.dot(d1) / speed2)) * (1.0 / speed2); + curvature = curvature.max((bend - f.bend).len() / reference.max(f.bend.len()).max(1e-30)); + } + } + Ok((gap, angle, curvature)) +} + +#[cfg(test)] +#[path = "corner_g2_tests.rs"] +mod tests; + +/// The G2 corner through the fillet path: a box's three edges at one corner rounded, rolling ball +/// against G2 patch, measured on the built bodies with the continuity analysis. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod fillet_corner_tests { + use super::*; + use crate::{ContinuityTolerances, SeamReport, measure, seam_diagnostics}; + + /// A 20 mm box with its three edges at one corner rounded by 4: the rolling ball fills that + /// corner with a piece of its sphere, the G2 route with a patch on the blends' own sections. + fn cornered(g2: bool) -> crate::Body { + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + if g2 { crate::fillet_g2_corners(&b, &edges, 4.0).unwrap() } else { crate::fillet(&b, &edges, 4.0).unwrap() } + } + + #[test] + fn the_ball_corner_is_measurably_g1_and_a_g2_corner_is_refused_until_it_sews() { + // The rolling ball: tangent to every blend, keeping its own 1/r across each corner seam. + let body = cornered(false); + let volume = measure(&body).unwrap().volume; + assert!(volume > 7000.0 && volume < 8000.0, "the box is still a solid: {volume}"); + let report = seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap(); + // Every seam of a filleted box is tangent; the corner's three are the short ones (the + // blend-to-face seams run the edge's own 12 mm and more). + let tangent: Vec<&SeamReport> = report.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + let corner: Vec<&&SeamReport> = tangent.iter().filter(|s| s.length < 8.0).collect(); + assert_eq!( + corner.len(), + 3, + "three corner seams among {} tangent ones: {:?}", + tangent.len(), + tangent.iter().map(|s| s.length).collect::>() + ); + let worst = corner.iter().map(|s| s.curvature_jump_per_mm.value).fold(0.0f64, f64::max); + eprintln!("rolling ball: corner seams jump {worst:.6} per mm (volume {volume:.3})"); + assert!((worst - 0.25).abs() < 1e-3, "the ball keeps its own 1/r (r = 4) across the seam: {worst}"); + + // The G2 corner patch is built on the blends' own sections, but sewing it into the shell + // is not done yet: the fillet with `corner: g2` refuses with a clear message rather than + // leaving the shell open, and leaves the ball the corner. + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + let err = crate::fillet_g2_corners(&b, &edges, 4.0).unwrap_err().to_string(); + eprintln!("G2 corner: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + } +} diff --git a/crates/kernel/src/corner_g2_tests.rs b/crates/kernel/src/corner_g2_tests.rs new file mode 100644 index 00000000..0968522b --- /dev/null +++ b/crates/kernel/src/corner_g2_tests.rs @@ -0,0 +1,154 @@ +//! What the G2 corner patch has to prove on a corner whose answer is known analytically: the box. + +use super::*; +use std::f64::consts::FRAC_PI_2; + +use crate::body::v3; + +/// One seam of a box corner: a quarter circle about `centre` from direction `u` to direction `w`, +/// with `leave` the way the patch leaves the blend's surface. A fillet is flat along its own axis, +/// so its curvature across the seam is nothing. +fn seam(centre: Vec3, u: Vec3, w: Vec3, leave: Vec3, r: f64, n: usize) -> Vec { + (0..n) + .map(|i| { + let theta = FRAC_PI_2 * i as f64 / (n - 1) as f64; + Frame { p: centre + (u * theta.cos() + w * theta.sin()) * r, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// A box corner at the origin (material x, y, z ≥ 0) with three fillets of radius `r`: the three +/// section curves where the fillets stop, meeting at (0,r,r), (r,0,r) and (r,r,0), and the middle of +/// the ball's octant a rolling ball would leave there. +fn box_corner(r: f64, n: usize) -> (Vec>, Vec3, Vec3) { + let c = Vec3::new(r, r, r); + let (a, b, d) = (Vec3::new(0.0, r, r), Vec3::new(r, 0.0, r), Vec3::new(r, r, 0.0)); + let dir = |p: Vec3| (p - c).normalized().unwrap_or(Vec3::X); + let sides = vec![ + // A → B on the z edge's cylinder (flat along z), B → C on the x edge's, C → A on the y + // edge's; each patch leaves the blend towards the material the corner caps. + seam(c, dir(a), dir(b), Vec3::new(0.0, 0.0, -1.0), r, n), + seam(c, dir(b), dir(d), Vec3::new(-1.0, 0.0, 0.0), r, n), + seam(c, dir(d), dir(a), Vec3::new(0.0, -1.0, 0.0), r, n), + ]; + let apex = c - Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z) * r; + (sides, apex, c) +} + +/// The rolling-ball corner the kernel builds today: the sphere through the same three seams, made +/// the way `curvecorner` makes it (a meridian arc swept about the axis through the ball's centre). +fn rolling_ball(r: f64, c: Vec3) -> mt::Surface { + let axis = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z); + let side = axis.any_perp(); + let meridian = builder::circle_arc(&builder::vertex(p3(c + axis * r)), &builder::vertex(p3(c - axis * r)), p3(c + side * r)); + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(c), v3(axis)))) +} + +/// The stations clear of the corners. Two blends meet at a corner with their seams 90° apart and +/// ask for different curvature at that one point, which no single patch can hold, and the corner's +/// own spline rounds the seam there; both effects fade within a few stations, so the measurement +/// starts clear of them. +fn interior(sides: &[Vec]) -> Vec> { + sides.iter().map(|s| s.iter().skip(4).take(s.len().saturating_sub(8)).copied().collect()).collect() +} + +#[test] +fn a_box_corner_is_curvature_continuous_with_every_blend() { + let r = 2.0; + let (sides, apex, centre) = box_corner(r, 25); + let faces = corner_g2_patch(&sides, apex).unwrap(); + let face = faces.first().expect("a corner patch"); + let surface = face.oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, 1.0 / r).unwrap(); + println!("G2 corner: gap {gap:e}, normal angle {angle:e} rad, curvature {curvature:e} (relative to 1/r)"); + assert!(gap <= r * 1e-5, "the patch is {gap} from the seam"); + // The stated tolerances: the normal angle within 0.01° and the curvature across the seam within + // 1% of the blend's own (here the fillet is flat across the seam, so that is 1% of 1/r). + assert!(angle < 0.01f64.to_radians(), "normal angle {angle} rad"); + assert!(curvature < 1e-2, "curvature across the seam is {curvature} of the blend's"); + + // The same measurement on the rolling-ball corner the kernel builds today: G1 (angle nought) + // but the ball's own 1/r across each seam, which is the whole of the blend's curvature. + let ball = rolling_ball(r, centre); + let (ball_gap, ball_angle, ball_curvature) = seam_error(&ball, &inner, 1.0 / r).unwrap(); + println!("rolling ball: gap {ball_gap:e}, normal angle {ball_angle:e} rad, curvature {ball_curvature:e}"); + assert!(ball_angle < 0.01f64.to_radians(), "the ball is tangent to each blend"); + assert!(ball_curvature > 0.5, "the ball keeps its own curvature across the seam: {ball_curvature}"); + assert!(curvature * 20.0 < ball_curvature, "the G2 patch has to be far closer than the ball"); +} + +#[test] +fn a_corner_meets_three_blends_of_different_shape_apart() { + // Blends that differ along their seams: a fillet of radius 2 next to rounder walls, each asking + // for its own curvature across the seam, at its own direction. + let r = 2.0; + let (mut sides, apex, _) = box_corner(r, 33); + let bends = [0.25, 0.4, 0.6]; + for (i, bend) in bends.iter().enumerate() { + let Some(side) = sides.get_mut(i) else { continue }; + for f in side.iter_mut() { + // Across the seam, perpendicular to both the leave direction and the seam. + f.bend = f.leave.any_perp() * *bend; + } + } + let faces = corner_g2_patch(&sides, apex).unwrap(); + let surface = faces.first().expect("a corner patch").oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, bends.iter().copied().fold(0.0, f64::max)).unwrap(); + println!("three different blends: gap {gap:e}, angle {angle:e} rad, curvature {curvature:e}"); + assert!(gap <= r * 1e-6, "{gap}"); + assert!(angle < 0.01f64.to_radians(), "{angle}"); + assert!(curvature < 1e-2, "each blend's own curvature across the seam: {curvature}"); +} + +#[test] +fn a_g2_corner_refuses_what_it_cannot_hold() { + let r = 2.0; + let (sides, apex, _) = box_corner(r, 9); + assert!(corner_g2_patch(&sides, apex).is_ok()); + + // Two blends (the ball's patch still fills that corner), or one. + assert!(corner_g2_patch(&sides[..2], apex).is_err()); + assert!(corner_g2_patch(&sides[..1], apex).is_err()); + assert!(corner_g2_patch(&[], apex).is_err()); + + // Too few stations to interpolate a cubic through, and too many to work with. + let thin: Vec> = sides.iter().map(|s| s[..3].to_vec()).collect(); + assert!(corner_g2_patch(&thin, apex).is_err()); + let fat: Vec> = sides.iter().map(|s| s[..1].iter().cycle().take(MAX_STATIONS + 1).copied().collect()).collect(); + assert!(corner_g2_patch(&fat, apex).is_err()); + + // A vertex the construction cannot hold: the section curves do not meet at their corners. + let mut apart = sides.clone(); + if let Some(first) = apart.first_mut().and_then(|s| s.first_mut()) { + first.p += Vec3::new(0.5, 0.0, 0.0); + } + assert!(corner_g2_patch(&apart, apex).is_err()); + + // A section curve that runs along the seam, so the patch has no direction to leave it in. + let along: Vec> = sides + .iter() + .map(|s| { + s.windows(2) + .map(|w| { + let d = w.first().zip(w.get(1)).map(|(a, b)| (b.p - a.p).normalized().unwrap_or(Vec3::X)).unwrap_or(Vec3::X); + Frame { leave: d, ..w.first().copied().unwrap_or(s[0]) } + }) + .chain(s.last().copied()) + .collect() + }) + .collect(); + assert!(corner_g2_patch(&along, apex).is_err()); + + // Hostile numbers never panic. + let mut nan = sides.clone(); + if let Some(f) = nan.first_mut().and_then(|s| s.get_mut(1)) { + f.p = Vec3::new(f64::NAN, 0.0, 0.0); + } + assert!(corner_g2_patch(&nan, apex).is_err()); + assert!(corner_g2_patch(&sides, Vec3::new(f64::INFINITY, 0.0, 0.0)).is_err()); + // A corner with no room: every station on the apex. + let on_apex: Vec> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect(); + assert!(corner_g2_patch(&on_apex, apex).is_err()); +} diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 6de2a465..d7234712 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1,10 +1,17 @@ //! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces -//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball of the -//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its -//! contacts the curves where the blend meets each side. The blend face is a rational B-spline -//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed -//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are -//! shortened to the contacts. +//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball rolls along +//! the edge touching both sides; its centre traces the spine and its contacts the curves where +//! the blend meets each side. The blend face is a rational B-spline through the circular +//! cross-sections (exact arcs at the samples), the side faces are trimmed back to the contact +//! curves, and seams that met the edge (lines, or arcs of an end face) are shortened to the +//! contacts. +//! +//! The ball's size along the chain is one of [`Size`]: one radius all along (the ordinary +//! fillet), a chord width across the blend (each section spans the requested width from contact +//! to contact, so its radius is `chord / (2 sin(ψ/2))` for the `ψ` between the two outward +//! normals where the ball touches), or a radius law along the chain (a [`RadiusSpline`] whose +//! positions are the fraction of the chain's arc length, zero only at an end, where the blend +//! closes to the edge's end point as the straight-edge fillet does). use std::collections::HashMap; @@ -12,6 +19,7 @@ use solidcraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::blend::too_large; +use crate::blend_variable::RadiusSpline; use crate::body::{Solid, from_p3, p3}; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -40,21 +48,60 @@ const TANGENT_RETRIES: usize = 8; const MAX_STEP_STRETCH: f64 = 3.0; /// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last. const KNIFE_STEPS: usize = 40; +/// Corrections of the radius a chord fillet makes to the width it measures across the blend. +const CHORD_STEPS: usize = 12; +/// A section this close to a point is closed: the blend ends at the edge's end point (a radius +/// that has run to zero there). +const CLOSED: f64 = 1e-7; +/// Parameters an edge is sampled at to measure the arc length a radius law is read off. +const LENGTH_STEPS: usize = 64; + +/// The ball's size along the edge chain. +#[derive(Clone, Copy)] +enum Size<'a> { + /// One radius all along. + Constant(f64), + /// The width across the blend, from contact to contact, at every station. + Chord(f64), + /// The radius at the station's arc-length fraction along the chain, 0 at the front and 1 at + /// the back of its edge. + Law(&'a RadiusSpline), +} struct Fit { surface: mt::Surface, contacts: [mt::Curve; 2], /// Parameter range belonging to this edge (the fit includes neighbouring sections). range: (f64, f64), + /// The part of the fit that is the chain's own run, as fractions of its parameter range. + span: (f64, f64), } struct Guide { sections: Vec
, + /// The size law's parameter (the arc-length fraction along the chain) at each section: the + /// radius between the samples is read off the law there, not off the spine's own length. + law: Vec, lengths: Vec, + /// The part of the guide that is the chain itself, as fractions of its length: the sections + /// run on past the ends of the chain (only so that the fit can be cut back there) and the fit + /// is checked for accuracy on the chain alone. + span: (f64, f64), } impl Guide { + /// A guide whose law parameter is the section's own fraction of it (the size is one radius, + /// which needs no law). fn new(sections: Vec
) -> Result { + let den = sections.len().max(2) - 1; + let law = (0..sections.len()).map(|i| i as f64 / den as f64).collect(); + Self::with_law(sections, law) + } + + fn with_law(sections: Vec
, law: Vec) -> Result { + if sections.len() != law.len() { + return Err(unsupported("degenerate rolling-ball spine")); + } let mut lengths = vec![0.0]; let mut total = 0.0; for pair in sections.windows(2) { @@ -65,10 +112,16 @@ impl Guide { if !total.is_finite() || total <= 1e-12 { return Err(unsupported("degenerate rolling-ball spine")); } - Ok(Self { sections, lengths }) + Ok(Self { sections, law, lengths, span: (0.0, 1.0) }) } + /// The centre and tangent at `along` of the spine. fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> { + self.anchor_at(along).map(|(centre, tangent, _)| (centre, tangent)) + } + + /// [`Guide::anchor`] with the size law's parameter at that station. + fn anchor_at(&self, along: f64) -> Option<(Vec3, Vec3, f64)> { if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) { return None; } @@ -81,7 +134,8 @@ impl Guide { return None; } let fraction = (distance - lo) / span; - Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?)) + let law = self.law.get(hi - 1)? * (1.0 - fraction) + self.law.get(hi)? * fraction; + Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?, law)) } } @@ -145,7 +199,7 @@ fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: boo }; let contacts = [contact(0)?, contact(2)?]; let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?; - Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range }) + Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range, span: (0.0, 1.0) }) } /// Compare the fitted strip with a solved circular section between interpolation nodes. @@ -263,7 +317,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2], // Both contacts are cut there: strictly inside the strip's own part. let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6; if !(inside(*t1) && inside(*t2)) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 309)")); } return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs))); } @@ -341,10 +395,10 @@ fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Ed }; let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o)); let v_t = rt.ok_or_else(fail)?; - let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?; + let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces (site 387)"))?; let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6; if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 390)")); } // How far the strip's point (u, v) is outside the end face (negative: inside the body). let outside = |u: f64, v: f64| -> Option { @@ -599,6 +653,93 @@ fn solve_from( None } +/// The section whose two contacts are `chord` apart: the ball's radius is `chord / (2 sin(ψ/2))` +/// for the `ψ` between the two outward normals where it touches, `|q1 − q2| = 2 r sin(ψ/2)`. The +/// normals turn as the ball grows against curved faces, so the width is measured on the solved +/// section and the radius corrected until it matches (the width is proportional to the radius, +/// so the corrections converge quickly). The normals at `p` give the first radius, exact where +/// both faces are planar; `None` where the faces meet too nearly tangentially to span a chord. +#[allow(clippy::too_many_arguments)] +fn solve_chord( + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + chord: f64, + sigma: f64, + cusp: f64, + extra: Option>, + tol: f64, +) -> Option<(Section, f64)> { + let normal = |fs: &[&mt::Face]| fs.iter().filter_map(|f| project(f, p, None)).min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|x| x.1); + let (n1, n2) = (normal(f1)?, normal(f2)?); + let half = (n1.dot(n2).clamp(-1.0, 1.0).acos() / 2.0).sin(); + if !(half > 1e-3) { + return None; + } + let mut r = chord / (2.0 * half); + for _ in 0..CHORD_STEPS { + if !(r.is_finite() && r > 0.0 && r < 1e9) { + return None; + } + let s = solve(f1, f2, p, t, r, sigma, cusp, extra, tol)?; + let width = s.q1.dist(s.q2); + if !width.is_finite() || width <= 0.0 { + return None; + } + if (width - chord).abs() <= (chord * 1e-9).max(tol * 0.5) { + return Some((s, r)); + } + r *= chord / width; + } + None +} + +/// The rolling ball at one station, sized by the blend's law. +struct Sizer<'a> { + size: Size<'a>, + /// The tolerance the section's equations are solved at. + tol: f64, +} + +impl Sizer<'_> { + /// The section at `p` (tangent `t`, the size law's parameter `s`) holding the third + /// condition `extra` when given, with the radius it was solved at. + #[allow(clippy::too_many_arguments)] + fn section( + &self, + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + s: f64, + sigma: f64, + cusp: f64, + extra: Option>, + ) -> Option<(Section, f64)> { + match self.size { + Size::Constant(r) => solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)), + Size::Chord(chord) => solve_chord(f1, f2, p, t, chord, sigma, cusp, extra, self.tol), + Size::Law(law) => { + let r = law_beyond(law, s)?; + if !(r.is_finite() && (0.0..1e6).contains(&r)) { + return None; + } + solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)) + } + } + } + + /// What a station the ball does not fit at is refused with: a chord too large is about the + /// size asked for, anything else about where the ball is. + fn refusal(&self) -> KernelError { + match self.size { + Size::Chord(_) => too_large("the blend is larger than the neighbouring faces (site 704)"), + _ => unsupported("the ball doesn't fit along the edge"), + } + } +} + /// Whether the strip's curvature jumps where two runs of sections meet (each three sections from /// the joint outward, the joint's first): for the centres or either contact, the difference of /// the circles through them would move the middle section by more than a tenth of `tolerance`. @@ -614,6 +755,12 @@ fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool { .any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1) } +/// Whether the section is a point: a blend whose radius has run to zero, closing to the edge's +/// end point there. +fn closed_section(s: &Section) -> bool { + s.c.dist(s.q1) <= CLOSED && s.c.dist(s.q2) <= CLOSED +} + /// Homogeneous control points of the circular (or straight) cross-section from q1 to q2. fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w); @@ -621,6 +768,11 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let m = (s.q1 + s.q2) * 0.5; return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]); } + if closed_section(s) { + // A closed section: the arc has shrunk to the edge's end point, all three controls + // there, so the fitted strips of the neighbouring faces meet at that vertex. + return Some([h(s.c, 1.0), h(s.c, 1.0), h(s.c, 1.0)]); + } let (a, b) = (s.q1 - s.c, s.q2 - s.c); let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos(); if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) { @@ -833,14 +985,14 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option .unwrap_or(end); let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?; if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 938)")); } Ok(t) }; let (ta, tb) = (at(&a, lo)?, at(&b, hi)?); let margin = (hi - lo) * 1e-9; if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 945)")); } if tb < hi { let _ = curve.cut(tb); @@ -851,16 +1003,100 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}"))) } +/// A monotone curve's parameter read off a table of its values at uniform parameters (`None` for +/// an empty table): the fraction of the way through it. +fn sampled(table: &[f64], along: f64) -> Option { + if table.len() < 2 || !along.is_finite() { + return None; + } + let x = along.clamp(0.0, 1.0) * (table.len() - 1) as f64; + let i = (x.floor() as usize).min(table.len() - 2); + let f = x - i as f64; + Some(table.get(i)? * (1.0 - f) + table.get(i + 1)? * f) +} + +/// The arc length of `edge` from its front to its back at each of `LENGTH_STEPS + 1` uniform +/// parameters, accumulated. `None` for an edge that has no length (or runs back over itself). +fn edge_lengths(edge: &mt::Edge) -> Option> { + use mt::{BoundedCurve, ParametricCurve}; + let c = edge.oriented_curve(); + let (a, b) = c.range_tuple(); + let mut last = from_p3(c.subs(a)); + let mut total = 0.0; + let mut out = Vec::with_capacity(LENGTH_STEPS + 1); + out.push(0.0); + for i in 1..=LENGTH_STEPS { + let p = from_p3(c.subs(a + (b - a) * i as f64 / LENGTH_STEPS as f64)); + total += last.dist(p); + last = p; + out.push(total); + } + (total.is_finite() && total > 1e-12).then_some(out) +} + +/// The radius a law gives at the chain fraction `s`, continued past 0 and 1 at the law's own end +/// slope. The ball runs on past a chain end (to be cut back there by its end face), and holding +/// the end radius would kink the strip exactly where the edge begins: a kink a cubic fit spreads +/// back over the whole blend. Never below a fifth of the end radius, so that a law running down +/// steeply still has a ball to run on with. +fn law_beyond(law: &RadiusSpline, s: f64) -> Option { + if (0.0..=1.0).contains(&s) { + return Some(law.eval(s)); + } + let (end, step) = if s < 0.0 { (0.0, s) } else { (1.0, s - 1.0) }; + let h = 1e-3; + let slope = (law.eval(end + h) - law.eval(end)) / h; + let r = law.eval(end); + let r = (r + slope * step).max(0.2 * r); + (r.is_finite() && r > 0.0).then_some(r) +} + /// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from /// `solid` (each blend face in place of its chain edge). pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) } +/// [`curve_blend`] with the blend `chord` wide across, from contact to contact, at every station. +pub(crate) fn curve_blend_chord(solid: &Solid, ids: &[mt::EdgeID], size: f64, chord: f64, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Chord(chord), round, MAX_SAMPLES) +} + +/// [`curve_blend`] with the radius following `law` along the chain (its positions the fraction of +/// the chain's arc length, from the front of its first edge to the back of its last). +pub(crate) fn curve_blend_law(solid: &Solid, ids: &[mt::EdgeID], size: f64, law: &RadiusSpline, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Law(law), round, MAX_SAMPLES) +} + fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { - if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { + curve_blend_sized(solid, ids, size, Size::Constant(r), round, max_samples) +} + +/// Blend the chain of edges (ids in any order) sized by `size`: one radius, a chord width, or a +/// radius law. With what it built from `solid` (each blend face in place of its chain edge). +fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Size<'_>, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { + if !(size.is_finite() && size > 0.0) { return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); } + match size_law { + Size::Constant(r) | Size::Chord(r) => { + if !(r.is_finite() && r > 1e-6 && r < 1e6) { + return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); + } + } + Size::Law(law) => { + let knots = law.knots.len() == law.radii.len() + law.degree + 1; + if law.radii.len() < 2 || !knots || law.radii.iter().any(|r| !(r.is_finite() && (0.0..1e6).contains(r))) { + return Err(KernelError::Invalid("a radius law along the edge must be finite and non-negative".into())); + } + // A law that runs to zero closes the blend at the edge's end point. The fitted strip + // pinches to a point there, and the sections' own interpolation cannot follow the last + // span of it: the straight-edge fillet, which builds that closure exactly, is the way. + if law.eval(0.0) <= CLOSED || law.eval(1.0) <= CLOSED { + return Err(unsupported("a radius law that runs to zero along a curved edge")); + } + } + } if !(4..=MAX_SAMPLES).contains(&max_samples) { return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into())); } @@ -991,10 +1227,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // below the first (against its outward normal) when the cusp is material, above when it is // a gap. The cross product of the normals, which tells the other edges apart, is zero here. let knife = 1.0 + n1.dot(n2) < KNIFE; + // The one radius, where the size is one radius (a chord or a law has none). + let fixed = match size_law { + Size::Constant(r) => Some(r), + _ => None, + }; + // The blend's scale where the size itself is not what matters (the knife edge's probe): the + // radius asked for, or the law's middle value. + let scale = match size_law { + Size::Constant(r) | Size::Chord(r) => r, + Size::Law(law) => law.eval(0.5).max(1e-6), + }; let (convex, cusp) = if knife { let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?; // Second order: from the point of face 1 a little way along `w`, face 2's nearest point. - let probe = project(f1, pm + w * r, None) + let probe = project(f1, pm + w * scale, None) .and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na))) .filter(|depth| depth.abs() > size * 1e-9) .ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?; @@ -1008,10 +1255,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if n1.dot(n2) > 1.0 - 1e-6 { return Err(unsupported("blending a smooth edge")); } + // The chain's arc length, in fractions of its whole, at each station: where the size is one + // radius it is not read, but the sections take their parameter from it all the same. + let tables: Vec> = chain.iter().map(|e| edge_lengths(e).unwrap_or_else(|| vec![0.0; LENGTH_STEPS + 1])).collect(); + let total: f64 = tables.iter().filter_map(|t| t.last().copied()).sum(); + if !matches!(size_law, Size::Constant(_)) && !(total.is_finite() && total > 1e-12) { + return Err(unsupported("a zero-length edge of the chain")); + } + let total = if total > 1e-12 { total } else { 1.0 }; + let mut cum: Vec = Vec::with_capacity(n + 1); + let mut acc = 0.0; + for table in &tables { + cum.push(acc); + acc += table.last().copied().unwrap_or(0.0); + } + cum.push(acc); + // A law that runs around a closed chain meets itself: its two ends must agree there. + if closed + && let Size::Law(law) = size_law + && (law.eval(0.0) - law.eval(1.0)).abs() > 1e-9 * law.radii.iter().copied().fold(1.0_f64, f64::max) + { + return Err(KernelError::Invalid("a closed edge chain runs back into itself: give the same radius law at both ends".into())); + } + // The law's parameter at `along` of edge `k` (its front to its back). + let frac_at = |k: usize, along: f64| -> f64 { + let (Some(table), Some(lo)) = (tables.get(k), cum.get(k)) else { return if k >= n { 1.0 } else { 0.0 } }; + let s = sampled(table, along).unwrap_or(0.0); + ((lo + s) / total).clamp(0.0, 1.0) + }; + let sizer = Sizer { size: size_law, tol: tol * 100.0 }; // Arcs on a planar side that curve round the ball: its centre runs round them at their // radius less the blend's. Less than nothing is too large; nothing is a pivot. let mut pivots: Vec> = vec![None; n]; - for (k, e) in chain.iter().enumerate() { + 'pivots: for (k, e) in chain.iter().enumerate() { + // A size that varies along the edge has no one radius to compare a corner's arc with. + let Some(r) = fixed else { break 'pivots }; let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue }; for side in [1, 2] { let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue }; @@ -1032,7 +1310,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } if rho < r - tol * 1e3 { - return Err(too_large("the blend is larger than a corner radius")); + return Err(too_large("the blend is larger than a corner radius (site 1257)")); } if (rho - r).abs() > tol * 1e3 { continue; @@ -1145,6 +1423,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?)) }; let mut vsec: Vec
= Vec::with_capacity(nv); + // The radius each vertex section was solved at (the closed end of a law: zero). + let mut vradius: Vec = Vec::with_capacity(nv); for k in 0..nv { let (p, t) = if k < n { tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0) @@ -1167,8 +1447,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let q = pv.c + (p - pv.o); vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } }); + vradius.push(fixed.unwrap_or_default()); continue; } + // The law's parameter at this vertex: the front of its edge, or the back of the last + // edge at an open chain's end. + let law_s = if k < n { frac_at(k, 0.0) } else { frac_at(n - 1, 1.0) }; if at_end { // A plane square to the edge holds the blend's end section. Any other end face: the // blend runs on past the end and is cut back where it meets that face. @@ -1181,7 +1465,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } _ => false, }; - if !square || split_ends.iter().any(|e| e.0 == k) { + // A radius that has run to zero closes the blend at the vertex itself: it does not run + // on past the end, and the end face keeps the corner it had there. + let closed_end = matches!(size_law, Size::Law(law) if law.eval(law_s) <= CLOSED); + if (!square || split_ends.iter().any(|e| e.0 == k)) && !closed_end { slanted.push((k, fi, p, t, None)); } } @@ -1212,7 +1499,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // longer is (a wall past the round at a corner, which a ball rolling round it pokes // through). let (a1, a2) = edge_sides(&[k.min(n - 1)]); - let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?; + let (s, r) = sizer.section(&a1, &a2, p, t, law_s, sigma, cusp, extra).ok_or_else(|| sizer.refusal())?; // A seam on the other side too must pass through that side's contact. if let Some((side, seam)) = here.get(1).filter(|_| !at_end) { let q = if *side == 1 { s.q1 } else { s.q2 }; @@ -1222,10 +1509,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } vsec.push(s); + vradius.push(r); } // Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near // at the chain's start, near to far at its end), far enough to cross the end face. - let mut beyond: Vec<(usize, Vec
)> = Vec::new(); + let mut beyond: Vec<(usize, Vec
, Vec)> = Vec::new(); for (k, fi, p, t, tangent) in slanted.iter_mut() { let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?; let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?; @@ -1250,12 +1538,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if cos < 0.15 && tangent.is_none() { return Err(unsupported("an end face almost along the edge")); } - let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r; + let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + vradius.get(*k).copied().unwrap_or(scale); let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos }; let out = if *k == 0 { -*t } else { *t }; + // The run on past the end only gives the fit its context (the end face cuts it back), but + // it keeps the law's end slope: the radius is read at the distance past the end. + let law_s = |d: f64| if *k == 0 { -d / total } else { 1.0 + d / total }; // Past a tangent end the straight run along the tangent leaves the curved spine: where // the ball no longer fits, the run is shortened (it only gives the fit its context). let mut list = Vec::with_capacity(EXTENSION); + let mut laws = Vec::with_capacity(EXTENSION); let mut reach = reach; // Past the end of a curved edge the planes square to its end tangent cut the ring of // centres further and further round it, until a step jumps across the ring (#1255: a @@ -1263,26 +1555,33 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // then, as long as one of its sections still lies wholly past the end face. let ng_out = if ng.dot(out) < 0.0 { -ng } else { ng }; let past = |sec: &Section| [sec.c, sec.q1, sec.q2].iter().all(|x| (*x - *p).dot(ng_out) > tol); - let mut fallback: Option> = None; + // A run shortened when its centres jump across the ring (a knuckle's cap circle, #1255) + // stands if no full-length run works; a size law's own end slope is read at each station's + // distance past the end. + let mut fallback: Option> = None; for attempt in 0..TANGENT_RETRIES { list.clear(); + laws.clear(); let step = reach / EXTENSION as f64; let sections = (1..=EXTENSION).map(|j| { - let anchor = *p + out * (step * j as f64); - solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0) + let d = step * j as f64; + let anchor = *p + out * d; + let s = law_s(d); + sizer.section(&e1, &e2, anchor, *t, s, sigma, cusp, None).map(|(section, _)| (s, section)) }); match sections.collect::>>() { Some(all) => { let smooth = tangent.is_some() - || std::iter::once(at.c).chain(all.iter().map(|s| s.c)).collect::>().windows(2).all(|w| match w { + || std::iter::once(at.c).chain(all.iter().map(|(_, s)| s.c)).collect::>().windows(2).all(|w| match w { [a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step, _ => true, }); if smooth { // A shortened run must still cross the end face; else the first, // full-length run stands. - if fallback.is_none() || all.iter().any(past) { - list = all; + if fallback.is_none() || all.iter().any(|(_, s)| past(s)) { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); } break; } @@ -1294,12 +1593,27 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, reach *= if tangent.is_some() { 0.5 } else { 0.75 }; } if list.is_empty() { - list = fallback.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?; + match fallback { + // The shortened run stands when the ring's step jumped, whatever its size. + Some(all) => { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); + } + // A size asked for beyond what the faces around the end hold is refused as such; + // one radius past the end is the rolling ball's own failure to fit. + None => { + return Err(match size_law { + Size::Constant(_) => unsupported("the ball doesn't fit past the end of the edge"), + _ => too_large("the blend is larger than the neighbouring faces (site 1472)"), + }); + } + } } if *k == 0 { list.reverse(); + laws.reverse(); } - beyond.push((*k, list)); + beyond.push((*k, list, laws)); } // Uniform refinement preserves a common spacing across neighbouring patches. Work is // capped: a difficult strip returns an error instead of accepting an inaccurate fit. @@ -1319,12 +1633,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1))) }) .collect::>()?; - let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result
{ + let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<(Section, f64)> { let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?; let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?; let anchor = p + t * (start * (1.0 - along) + end * along); let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) + sizer.section(&a1, &a2, anchor, t, frac_at(k, along), sigma, cusp, None).ok_or_else(|| sizer.refusal()) }; let mut samples = SAMPLES.min(max_samples); let (esec, fits) = loop { @@ -1339,18 +1653,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; + let mut law = vec![frac_at(k, 0.0)]; + let (mut front_beyond, mut back_beyond) = (0usize, 0usize); for j in 1..samples - 1 { - list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?); + let along = j as f64 / (samples - 1) as f64; + let (section, _) = section_at(k, e, along)?; + list.push(section); + law.push(frac_at(k, along)); } list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); - for (kv, ext) in &beyond { + law.push(frac_at(k, 1.0)); + for (kv, ext, exts) in &beyond { if *kv == k && k == 0 { list.splice(0..0, ext.iter().copied()); + law.splice(0..0, exts.iter().copied()); + front_beyond = ext.len(); } else if *kv == k + 1 && k + 1 == nv - 1 { list.extend(ext.iter().copied()); + law.extend(exts.iter().copied()); + back_beyond = ext.len(); } } - guides.push(Some(Guide::new(list)?)); + let mut guide = Guide::with_law(list, law)?; + // The chain's own run: the spliced sections at either end are context for the fit + // only (the blend is cut back where it meets the end face there). + let last = guide.lengths.last().copied().unwrap_or(0.0); + let at = |i: usize| if last > 0.0 { guide.lengths.get(i).copied().unwrap_or(0.0) / last } else { 0.0 }; + guide.span = (at(front_beyond), at(guide.sections.len().saturating_sub(1 + back_beyond))); + guides.push(Some(guide)); } // Intersection curves inherit their leader's piecewise parameter speed. Fitting // those parameters creates oscillations even on a geometrically smooth spine. @@ -1364,9 +1694,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if along == 1.0 { return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into())); } - let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; + let (anchor, tangent, law_s) = guide.anchor_at(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0) + sizer + .section(&a1, &a2, anchor, tangent, law_s, sigma, cusp, None) + .map(|(section, _)| section) .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) }; let esec: Vec> = (0..n) @@ -1432,6 +1764,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, (Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?, _ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?, }; + let mut fit = fit; + fit.span = guides.get(k).and_then(Option::as_ref).map(|g| g.span).unwrap_or((0.0, 1.0)); fits.push(Some(fit)); } let mut accurate = true; @@ -1440,6 +1774,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, for j in 0..samples - 1 { for fraction in [0.25, 0.5, 0.75] { let along = (j as f64 + fraction) / (samples - 1) as f64; + // A size that varies along the edge reaches past its ends only for the fit's + // context (the end face cuts that away), so it is not held to the tolerance + // there. One radius keeps the check it always had, the whole strip. + if !matches!(size_law, Size::Constant(_)) + && fits.get(k).and_then(Option::as_ref).is_some_and(|f| along < f.span.0 || along > f.span.1) + { + continue; + } let section = uniform_section(k, along)?; if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, along, round, sigma, fit_tolerance)))? { accurate = false; @@ -1515,7 +1857,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, && n == 1 && !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1668)")); } crate::guard("curve blend", || { let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); @@ -1530,6 +1872,18 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, *b = builder::vertex(curve.subs(hi)); } } + // A closed section (a radius that has run to zero) has one vertex for both contacts, so + // the blend face's wire and the side faces' meet there. + for (k, s) in vsec.iter().enumerate() { + if !closed_section(s) { + continue; + } + if let Some(v) = v1.get(k).cloned() + && let Some(slot) = v2.get_mut(k) + { + *slot = v; + } + } // Along a pivot the planar side's contact is one point: one vertex at both its ends. for (k, pv) in pivots.iter().enumerate() { let Some(pv) = pv else { continue }; @@ -1557,18 +1911,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } } - let mut arcs = Vec::with_capacity(nv); + // The section's own radius, where the blend is sized by a law or a chord width (`r` is the + // one radius of a constant blend, exactly as before). + let rad = |s: &Section| match size_law { + Size::Constant(r) => r, + Size::Chord(_) | Size::Law(_) => s.c.dist(s.q1), + }; + let mut arcs: Vec> = Vec::with_capacity(nv); let mut split_arcs: HashMap> = HashMap::new(); for (k, s) in vsec.iter().enumerate() { let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); - let e = if round { - let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r; - builder::circle_arc(a, b, p3(m)) + // A closed section has no arc to run between the contacts: the blend meets itself + // there, at the edge's end point. + let e = if closed_section(s) { + None + } else if round { + let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * rad(s); + Some(builder::circle_arc(a, b, p3(m))) } else { - builder::line(a, b) + Some(builder::line(a, b)) }; let e = match caps.iter().find(|c| c.0 == k) { - Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?, + Some((_, _, _, _, curve)) => Some(mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?), None => e, }; if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) { @@ -1581,7 +1945,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result { use mt::{ParametricSurface3D, SearchNearestParameter}; let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; - let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; + let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * rad(s); if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { let nn = surface.normal(u, v); let want = (s.c - mid) * sigma; @@ -1688,7 +2052,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .filter(|f| f.edge_iter().any(|e| e.id() == id)) .any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol)); if !fits || !crate::blend::on_extension(&seam, q, on_tol) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1853)")); } } seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?); @@ -1708,7 +2072,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0)); let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0)); if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1873)")); } let a = a.unwrap_or_else(|| seam.front().clone()); let b = b.unwrap_or_else(|| seam.back().clone()); @@ -1723,7 +2087,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if corners.iter().any(|k| split_arcs.contains_key(k)) { let connectors: Vec = corners .iter() - .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect())) + .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().flatten().into_iter().collect())) .collect(); let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?; rec.face(&rebuilt, f); @@ -1754,8 +2118,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, es.push(ne.clone()); } if let Some(k) = arrives { - let arc = arcs.get(k).ok_or_else(|| fail("arc"))?; - let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); + // A closed section has no blend arc at the corner: the blend ends at + // the vertex itself and the end face keeps the corner it had. + let (Some(arc), Some(a1), Some(a2)) = (arcs.get(k).and_then(Option::as_ref), v1.get(k), v2.get(k)) else { + continue; + }; let to = if used_up { e.front() } else { ne.back() }; let corner = if to == a1 { arc.clone() @@ -1810,9 +2177,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } // Blend faces: each runs its side-1 contact the other way from side 1's face. for (k, bf) in blends.iter().enumerate() { - let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % nv)) else { + let (Some(e1), Some(e2)) = (c1.get(k), c2.get(k)) else { return Err(fail("blend")); }; + let ak = arcs.get(k).and_then(Option::as_ref); + let ak1 = arcs.get((k + 1) % nv).and_then(Option::as_ref); let f1i = side1.get(k).ok_or_else(|| fail("side"))?; let f1 = faces.get(*f1i).ok_or_else(|| fail("side"))?; let chain_e = chain.get(k).ok_or_else(|| fail("edge"))?; @@ -1824,20 +2193,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .map(|e| e.front() == chain_e.front()) .ok_or_else(|| fail("edge use"))?; // Side 1 runs the contact forward (as the chain) when it ran the edge forward. - // A pivot's planar contact is a point: three sides. - let edges = if used_fwd { - [e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] + // A pivot's planar contact is a point: three sides. A closed section has no arc: two. + let edges: Vec = if used_fwd { + [e1.as_ref().map(|e| e.inverse()), ak.cloned(), e2.clone(), ak1.map(|e| e.inverse())] } else { - [e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())] + [e1.clone(), ak1.cloned(), e2.as_ref().map(|e| e.inverse()), ak.map(|e| e.inverse())] } .into_iter() .flatten() - .collect::>(); + .collect(); + // An arc the chamfer crossed is two lines through the crossing: the blend meets the + // shortened seam there. let wire: mt::Wire = edges .into_iter() .flat_map(|e| { let split = [(k, ak), ((k + 1) % nv, ak1)] .into_iter() + .filter_map(|(i, arc)| arc.map(|arc| (i, arc.clone()))) .find(|(_, arc)| arc.id() == e.id()) .and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts))); match split { @@ -1861,8 +2233,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, #[path = "curvecorner.rs"] mod corner; +pub(crate) use corner::corner_blend_style; +// The planar corner builder the G2 corner work left in place of `corner_blend_style`'s sphere. +#[allow(unused_imports)] pub(crate) use corner::corner_blend; #[cfg(test)] #[path = "curveblend_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "curveblend_law_tests.rs"] +mod law_tests; diff --git a/crates/kernel/src/curveblend_law_tests.rs b/crates/kernel/src/curveblend_law_tests.rs new file mode 100644 index 00000000..f9d5195d --- /dev/null +++ b/crates/kernel/src/curveblend_law_tests.rs @@ -0,0 +1,315 @@ +//! Chord-width and radius-law blends along curved edges: the width across the blend at every +//! station, and the radius along the edge, measured on the body the kernel built against values +//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis). + +use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; +use solidcraft_geom::Vec3; + +use super::*; +use crate::blend::vtx; +use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline}; +use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export}; + +/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on +/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces +/// run from 63° to 117° apart around it. +fn oblique_boss() -> (Body, Vec3, Vec3) { + let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap(); + let base = Vec3::new(0.0, 0.0, -10.0); + let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap(); + let rod = cylinder(base, axis, 8.0, 30.0).unwrap(); + (boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis) +} + +/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a +/// cylinder away from the front (the wall curves into the part), otherwise the part is the box +/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base +/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it). +fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) { + let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap(); + let (axis, op, side) = + if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) }; + let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap(); + let body = boolean(&block, &tool, op).unwrap().unwrap(); + (body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side) +} + +/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces +/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves). +fn blend_face(input: &Body, body: &Body) -> Option { + use mt::ParametricSurface; + let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6)); + body.solid + .face_iter() + .filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_))) + .find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5)))) + .cloned() +} + +/// The edges of the blend face's boundary, each once, with the point at the middle of it (the +/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face). +fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> { + let Some(f) = blend_face(input, body) else { return Vec::new() }; + let mut out: Vec<(mt::Edge, Vec3)> = Vec::new(); + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + if out.iter().any(|(x, _)| x.id() == e.id()) { + continue; + } + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5)))); + } + } + out +} + +/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out. +fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec { + blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect() +} + +/// The fraction of the way along `edge` (its absolute front to its back) of the point of it +/// nearest `q`. +fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option { + let e = edge.absolute_clone(); + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = vec![from_p3(c.subs(lo))]; + let mut run = vec![0.0]; + for i in 1..=steps { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64)); + run.push(run.last().copied()? + at.last().copied()?.dist(p)); + at.push(p); + } + let total = *run.last()?; + let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY); + let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0); + let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?; + let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64); + let (k, f) = (i.floor() as usize, i.fract()); + let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None }; + Some((a + (b - a) * f) / total) +} + +/// The azimuth of `p` about the Z axis through `axis`. +fn azimuth(axis: Vec3, p: Vec3) -> f64 { + (p.y - axis.y).atan2(p.x - axis.x) +} + +/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`. +fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option { + let off = |p: Vec3| { + let mut d = azimuth(axis, p) - phi; + while d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + while d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } + d + }; + let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY); + for e in edges { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = lo; + let mut dist = f64::INFINITY; + for i in 0..=steps { + let t = lo + (hi - lo) * i as f64 / steps as f64; + let d = off(from_p3(c.subs(t))).abs(); + if d < dist { + (at, dist) = (t, d); + } + } + let step = (hi - lo) / steps as f64; + let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi)); + let g = 0.618_033_988_749_894_9; + for _ in 0..80 { + let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0)); + if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() { + x1 = b; + } else { + x0 = a; + } + } + let t = 0.5 * (x0 + x1); + let d = off(from_p3(c.subs(t))).abs(); + if d < best_d { + (best, best_d) = (from_p3(c.subs(t)), d); + } + } + (best_d < 1e-6).then_some(best) +} + +/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from +/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean +/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip +/// back there is only placed for one radius, so a width that varies along the edge is refused +/// rather than approximated (the fixed radius of `fillet` along the same edge rolls). +#[test] +fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() { + let (body, _, _) = oblique_boss(); + let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0); + // A fixed radius rolls along the same chain: the edge itself is supported. + assert!(fillet(&body, &[pick], 2.5).is_ok()); + let e = fillet_chord(&body, &[pick], 4.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); +} + +/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend +/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant +/// fillet of that radius) and its two contacts are that far apart at every station. +#[test] +fn chord_fillet_along_an_arc_edge() { + let (chord, r) = (3.0, 3.0 / 2f64.sqrt()); + let (body, mid, axis, _) = arc_walled_box(false); + let blended = fillet_chord(&body, &[mid], chord).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let plain = fillet(&body, &[mid], r).unwrap(); + let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume); + assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}"); + // The contact on the plane and the one on the wall at the same azimuth: the width across. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6); + assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's"); + let mid_azimuth = azimuth(axis, mid); + let mut worst: f64 = 0.0f64; + let mut stations = 0; + for i in 1..12 { + let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9; + let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue }; + stations += 1; + worst = worst.max((p.dist(w) - chord).abs()); + } + eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations"); + assert!(stations >= 9, "only {stations} stations measured"); + assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}"); +} + +/// A chord fillet on a straight edge between planar faces is still the exact local round: the +/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is +/// r = c / √2). +#[test] +fn chord_fillet_on_a_straight_edge_is_unchanged() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0); + let f = fillet_chord(&b, &[at], chord).unwrap(); + let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap(); + assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap()); + assert_eq!(f.unmeshed_faces(), 0); +} + +/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction +/// of the edge's length rolls along it, and the setback on the planar face — the plane and the +/// wall meet at a right angle, so it is the section's radius — follows the law from end to end. +#[test] +fn variable_radius_along_an_arc_edge_follows_the_law() { + let (body, mid, axis, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + // Started from the edge's back end: the law runs the other way, as the positions are from the + // end nearest `start`. + let start = vtx(edge.absolute_back()); + // A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip: + // the sections' own last spans cannot be held to the fit's accuracy there. That is refused, + // not approximated. + for wide in [4.0, 5.0] { + let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err(); + assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}"); + } + let (r0, r1) = (2.0, 3.4); + let blended = fillet_variable(&body, mid, start, r0, r1).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false); + // The law runs from the end nearest `start`: the chain's front here is the far end. + let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() }; + // `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1 + // at the front. + assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12); + // The blend's contact on the bottom plane is at 40 − r from the axis. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + assert!(!plane.is_empty(), "no contact on the bottom plane"); + let mut worst: f64 = 0.0; + let mut stations = 0; + let mut shown = Vec::new(); + for e in &plane { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + for i in 0..=32 { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0)); + let measured = 40.0 - (p - axis).len(); + let along = along_fraction(&edge, p).unwrap(); + // Leave the two ends, where the caps cut the contact: the blend closes on the end + // faces there. + if !(0.04..=0.96).contains(&along) { + continue; + } + stations += 1; + if shown.len() < 3 || (0.45..0.55).contains(&along) { + shown.push((along, measured, want.eval(along))); + } + worst = worst.max((measured - want.eval(along)).abs()); + } + } + eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}"); + assert!(stations >= 24, "only {stations} stations measured"); + assert!(worst < 0.02, "the section radius is {worst} mm off the law"); + // Wider, still inside what the strip holds: the same body as the exact straight-edge case of + // that law is not built here, but the radius is the law's at every station measured. +} + +/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would +/// close to the edge's end point, but the fitted strip pinches to a point there and the last span +/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is +/// the way (see `variable_radius_down_to_zero_at_one_end`). +#[test] +fn a_law_running_to_zero_along_a_curved_edge_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] { + let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err(); + assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}"); + let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] }; + assert!(fillet_variable_spline(&body, mid, mid, &law).is_err()); + // A law that stays clear of zero along it rolls: the blend is closed, a three-sided face + // where nothing closes it. + let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap(); + assert!(made.validity().is_empty(), "{:?}", made.validity()); + let mesh = made.tessellate_with(5e-4, true).unwrap(); + let about = |o: Vec3| { + mesh.triangles + .iter() + .map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0) + .sum::() + }; + assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed"); + } +} + +/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size, +/// never a silent approximation and never a panic. +#[test] +fn a_chord_or_radius_the_faces_cannot_hold_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + // A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block). + let e = fillet_chord(&body, &[mid], 30.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok()); + // A radius law too large for the wall. + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err()); + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err()); + // Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched. + let bad = |radii: Vec| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii }; + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err()); + assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err()); + // An edge the body does not have is refused by name, not approximated. + assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err()); +} diff --git a/crates/kernel/src/curvecorner.rs b/crates/kernel/src/curvecorner.rs index 46b943d0..affabdaa 100644 --- a/crates/kernel/src/curvecorner.rs +++ b/crates/kernel/src/curvecorner.rs @@ -25,6 +25,7 @@ use super::{ }; use crate::blend::{too_large, vtx}; use crate::body::{Solid, from_p3, p3, v3}; +use crate::bridge::Frame; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -683,9 +684,65 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec)], Err(fail("a boundary does not close")) } +/// The strip's own station nearest the corner ball `c`: the section where the strip stops against +/// that ball, where its direction of leave and its curvature are read. +fn corner_station(strip: &Strip, c: Vec3) -> Option { + strip.sections.iter().min_by(|a, b| a.1.c.dist(c).total_cmp(&b.1.c.dist(c))).map(|x| x.0) +} + +/// One side of a G2 corner: the corner ball's own section arc of radius `r` about `c`, from its +/// contact `q_from` to its contact `q_to`, each station carrying the direction the corner patch +/// leaves the blend in (towards the corner's apex) and the blend's own curvature across it, read +/// from `strip` where the ball stops against it. A rolling ball's section is a circular arc about +/// its own centre wherever the edge runs, so this arc is the blend's section curve itself, and its +/// ends are the ball's contacts exactly — the sides close the loop whatever the fit's station +/// spacing is. +fn g2_side(strip: &Strip, c: Vec3, r: f64, q_from: Vec3, q_to: Vec3, apex: Vec3) -> Result> { + use mt::ParametricSurface; + /// Stations across one section: enough for the patch's cubic cross-sections, few enough to + /// keep the corner's own patch small. + const STATIONS: usize = 9; + let vec_of = |d: mt::Vector3| Vec3::new(d.x, d.y, d.z); + let v = corner_station(strip, c).ok_or_else(|| fail("corner section"))?; + let a = (q_from - c).normalized().ok_or_else(|| fail("corner contact"))?; + let b = (q_to - c).normalized().ok_or_else(|| fail("corner contact"))?; + let dot = a.dot(b).clamp(-1.0, 1.0); + let w = (b - a * dot).normalized().ok_or_else(|| fail("corner contact"))?; + let theta = dot.acos(); + // The blend's own direction of leave and curvature across the seam, at its corner station. + let speed = vec_of(strip.surface.vder(0.5, v)).len(); + if !(speed > 1e-30) { + return Err(unsupported("a corner section of no width")); + } + let leave = vec_of(strip.surface.vder(0.5, v)) * (1.0 / speed); + let bend = vec_of(strip.surface.vvder(0.5, v)) * (1.0 / (speed * speed)); + if !(leave.is_finite() && bend.is_finite() && theta.is_finite() && r.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + let mut out = Vec::with_capacity(STATIONS); + for k in 0..STATIONS { + let t = theta * k as f64 / (STATIONS - 1) as f64; + let p = c + (a * t.cos() + w * t.sin()) * r; + // The patch leaves the blend towards the material the corner caps, so the sign is the one + // towards the corner's apex (the blends' own v direction runs away from the vertex). + let leave = if leave.dot(apex - p) < 0.0 { -leave } else { leave }; + if !(p.is_finite() && leave.is_finite() && bend.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + out.push(Frame { p, leave, bend, bend_change: Vec3::ZERO }); + } + Ok(out) +} + /// Blend the edges `ids` of `solid` with radius `r` (a chamfer's distance), rebuilding the /// corners at their ends; with what it built from `solid`. pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { + corner_blend_style(solid, ids, size, r, round, false) +} + +/// [`corner_blend`]; with `g2`, the corners where three blends meet are G2 patches +/// ([`crate::corner_g2`]) instead of pieces of the rolling ball's sphere. +pub(crate) fn corner_blend_style(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, g2: bool) -> Result<(Solid, Rebuild)> { if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { return Err(KernelError::Invalid("blend size and radius must be finite and positive".into())); } @@ -1006,6 +1063,33 @@ pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z); let side = out.any_perp(); let (np, sp, back) = (c + side * r, c - side * r, c - out * r); + if g2 { + // A G2 patch on the blends' own end sections, gathered at the ball's pole + // (the point the sphere's meridians are built through above). Each side is + // the strip's section through the ball's contact with the face it shares + // with the previous strip; the strips run on past their edges, so that + // station is found by projecting the contact point, not taken from the + // edge's own end. + let s2 = strip_of(e2)?; + let s3 = strip_of(e3)?; + // The cap's own pole: the ball's point nearest the corner (the sphere + // patch's `back` above is its opposite pole, where the meridian starts). + let apex = c + out * r; + // Side 1 runs from the ball's contact with the face it shares with e3 + // round to the one it shares with e2, and so on round the loop: each + // side's last station is the next side's first, at that contact. + let sides = [g2_side(s1, c, r, q31, q12, apex)?, g2_side(s2, c, r, q12, q23, apex)?, g2_side(s3, c, r, q23, q31, apex)?]; + let _patch = crate::corner_g2::corner_g2_patch(&sides, apex)?; + // Sewing the patch into the shell is not done yet: it brings its own + // boundary vertices where the strips' contacts end at the ball's contacts + // (`v12`/`v23`/`v31`), and a truck vertex is its allocation, not its point, + // so the rebuilt shell does not close there (rewrapping the section curves + // onto the contacts is the missing step). Refused rather than left open: + // the rolling ball fills the corner instead. + return Err(unsupported( + "a G2 corner sewn between these blends (the patch's section curves do not join the blends' contacts yet)", + )); + } let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( meridian.oriented_curve(), diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 92c076a0..94acc8c1 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -442,7 +442,7 @@ fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: add(p)?; } } - Seg2::Bezier { n, p } if (1..=7).contains(&n) => { + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { for p in p.iter().take(usize::from(n) + 1) { add(*p)?; } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 22756f87..daf37d7b 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -28,7 +28,9 @@ mod boundary; mod bridge; mod build; mod clearance; +mod continuity; mod coplanar; +mod corner_g2; mod curveblend; mod delete_face; #[cfg(test)] @@ -67,6 +69,7 @@ mod issue_871_tests; #[cfg(test)] mod join_between_caps_tests; mod lip; +pub mod loft_blend; mod loopblend; mod matchsurf; mod measure; @@ -123,7 +126,7 @@ mod tspline; mod untouched_faces_tests; pub mod watch; -pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_max, fillet_max_each}; +pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each}; pub use blend_corner::fillet_radii; pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; @@ -134,6 +137,10 @@ pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, sweep_with, torus, }; +pub use continuity::{ + ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, +}; +pub use corner_g2::{corner_g2_patch, seam_error}; pub use delete_face::delete_faces; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use fair::{ @@ -145,6 +152,7 @@ pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; pub use lip::raise_band; +pub use loft_blend::loft_blend; pub use matchsurf::{match_surface, seam_continuity}; pub use measure::{BodyMeasure, measure}; pub use mesh_convert::convert_prismatic_mesh; diff --git a/crates/kernel/src/loft_blend.rs b/crates/kernel/src/loft_blend.rs new file mode 100644 index 00000000..2364b200 --- /dev/null +++ b/crates/kernel/src/loft_blend.rs @@ -0,0 +1,550 @@ +//! Multi-section blends: one face that runs through many cross-sections and still leaves the +//! first and last of them the way a support surface wants. +//! +//! The sections run in order along the face's **u**, each sampled at the same `samples` stations +//! along the face's **v**. Section `i` sits at `u = i/(n−1)`, and the face passes through it +//! exactly: its v-curve there is that section's own cross-section curve through its stations. +//! Along v the stations are interpolated by a B-spline of degree `min(3, samples − 1)` — the same +//! interpolation [`crate::bridge`] builds its boundary curves with — so every station of every +//! section lies on the face. +//! +//! Along u the face is a B-spline whose knots are clamped at both ends and repeated at every +//! interior section, so every span is one Bézier; a span's leading control rows are the Hermite +//! rows of `bridge::section` — position, tangent (`leave`), curvature (`bend`) and change of +//! curvature (`bend_change`), scaled to that span's parameter interval. The degree is the largest +//! `c + c′ − 1` over the spans (`c` conditions at the section the span leaves, `c′` at the one it +//! arrives at), and a span whose two ends carry fewer conditions than that is elevated exactly, +//! which moves no point of the curve. A blend of two sections is therefore the [`crate::bridge`] +//! of those two boundaries, only with the two parameters the other way round. +//! +//! **The first and last section** take their tangent, curvature and change of curvature from the +//! caller's frames, exactly as a bridge takes them from a support surface's boundary, and hold +//! them to the requested [`Continuity`] (G0 up to G3). The `leave` direction of the last section's +//! frames points back into the blend, towards the interior, as a bridge's far boundary does. +//! +//! **Interior sections** take nothing from the caller's frames but their positions: the run +//! direction's tangent there is the secant of the two neighbouring sections' stations, its +//! curvature their second difference and its change of curvature their third difference, all +//! along the run. The two spans that meet at such a section are given exactly those rows, so they +//! agree in ∂/∂u, ∂²/∂u² and ∂³/∂u³ there and the joint is **C³** in the run parameter (hence G³) +//! whenever a fourth station is there to take the third difference over — that is, whenever the +//! blend runs through at least four sections. A blend of exactly three sections has only its one +//! interior section's two neighbours to read, so no third difference can be taken: the interior +//! section then keeps position, tangent and curvature only and its joint is **C²** (G²). Dropping +//! interior conditions, never the surface, is also how this blend stays within +//! [`MAX_SPLINE_DEGREE`]: a section family asks for at most `c + c′ − 1 ≤ 7` at the four conditions +//! a section can carry today, and a caller that ever asked for more would lose interior +//! conditions (change of curvature first, then curvature, then tangent) rather than fail. +//! +//! `crown` scales how fast the face leaves the first and the last section (0 the plain blend, up +//! to 3 fuller, down to −0.9 flatter), exactly as a bridge's crown does. It shapes the middle of +//! each end span and leaves every joint's conditions alone. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{CurveN, HPoint, MAX_SPLINE_DEGREE, NurbsSurface}; +use truck_modeling as mt; +use truck_modeling::{ParametricSurface3D, SearchNearestParameter}; + +use crate::body::{Body, from_p3, p3}; +use crate::bridge::{Continuity, Frame}; +use crate::surfaces::surface_body; +use crate::{KernelError, Result, guard}; + +/// Most cross-sections one blend may run through. +pub const MAX_BLEND_SECTIONS: usize = 50; +/// Fewest stations one cross-section may be sampled at (a straight line across it). +pub const MIN_BLEND_STATIONS: usize = 2; +/// Most stations one cross-section may be sampled at. The control net (and the interpolation that +/// builds it) grows with the station count, and no cross-section curve needs more than this. +pub const MAX_BLEND_STATIONS: usize = 257; + +/// How many rows of the control net one interpolation solve carries at a time: a row is three +/// coordinates, so this many rows are solved together. +const ROWS_PER_SOLVE: usize = 16; + +/// A blend surface through `sections`, in order, that leaves the first and the last of them with +/// the continuity their frames ask for. +/// +/// Each section is one cross-section: `samples` stations (`samples` must match every section's +/// length, so that the stations correspond from one section to the next). Section `i` is +/// interpolated at `u = i/(n−1)`, and its stations run along `v`, so the face of the body holds +/// every station of every section. `orders[0]` and `orders[1]` are what the face holds at the +/// first and the last section, read from their frames; the interior sections join the face +/// smoothly by finite differences along the run (see the module comment for the order each joint +/// holds). `crown` scales how fast the face leaves the first (`crown[0]`) and the last +/// (`crown[1]`) section and must be within −0.9 and 3. +/// +/// Refused, without ever panicking: fewer than 2 or more than [`MAX_BLEND_SECTIONS`] sections, +/// sections of unequal station counts or a `samples` below [`MIN_BLEND_STATIONS`] or above +/// [`MAX_BLEND_STATIONS`], a frame that is not finite or a `leave` with no direction, a crown +/// outside its range, and two neighbouring sections that meet at a station (no room for a blend). +pub fn loft_blend(sections: &[Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result { + let blend = Blend::new(sections, orders, crown, samples)?; + let net = blend.net()?; + guard("loft blend surface", || crate::spline_conv::tensor_face(&net).map(|f| surface_body(vec![f]))) +} + +/// The frames of one cross-section picked as an edge chain of a body, sampled at `stations` +/// stations: the chain itself, with the neighbouring face's tangent plane, curvature vector and +/// change of curvature read along `leave` — the direction the blend runs on from this section — +/// exactly as [`crate::bridge::frames`] reads a support surface's own boundary. +/// +/// Hand the result to [`loft_blend`] as one section. The face the section repeats is the body's +/// face that holds the whole chain and faces the run best; `leave` must have a part in that face's +/// tangent plane (a direction normal to it has no tangent plane to leave in). The last section's +/// `leave` runs back into the blend, towards the section before it. +pub fn section_frames(body: &Body, edges: &[Vec3], leave: Vec3, stations: usize) -> Result> { + guard("loft blend section", || { + let chain = crate::bridge::chain(body, edges)?; + let leave = + leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?; + let first = chain.first().ok_or_else(|| KernelError::Failed("loft blend: no chain".into()))?; + let mid = (from_p3(first.front().point()) + from_p3(first.back().point())) * 0.5; + // The face the chain lies on that the blend leaves through: the one whose tangent plane + // holds as much of `leave` as possible. + let mut best: Option<(f64, mt::Face)> = None; + for face in body.solid.face_iter() { + if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) { + continue; + } + let surf = face.surface(); + let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue }; + let normal = surf.normal(u, v); + let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO); + let score = (leave - n * leave.dot(n)).len(); + if best.as_ref().is_none_or(|(b, _)| score > *b) { + best = Some((score, face.clone())); + } + } + let (score, face) = best.ok_or_else(|| KernelError::Invalid("no face of the body holds the picked edges".into()))?; + if score < 0.2 { + return Err(KernelError::Invalid("the direction towards the next section lies in no face at the picked edges".into())); + } + crate::bridge::frames(&face, &chain, leave, stations) + }) +} + +/// What the face has to do at one section: where it is, and the run direction's first, second and +/// third derivatives at each of the section's stations. The third is carried only when the +/// section holds a change-of-curvature condition. +struct Shape { + p: Vec, + d1: Vec, + d2: Vec, + d3: Option>, +} + +/// The blend's inputs, validated, with the conditions and the run direction's degree worked out. +struct Blend<'a> { + sections: &'a [Vec], + crown: [f64; 2], + samples: usize, + run: usize, + /// The run parameter interval between two neighbouring sections: `1/(run − 1)`. + step: f64, + degree: usize, + /// Conditions held at each section, one per control row of the spans that end there. + conditions: Vec, +} + +impl<'a> Blend<'a> { + fn new(sections: &'a [Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result> { + if !(MIN_BLEND_STATIONS..=MAX_BLEND_STATIONS).contains(&samples) { + return Err(KernelError::Invalid(format!( + "a blend needs {MIN_BLEND_STATIONS} to {MAX_BLEND_STATIONS} stations per cross-section, not {samples}" + ))); + } + let run = sections.len(); + if !(2..=MAX_BLEND_SECTIONS).contains(&run) { + return Err(KernelError::Invalid(format!("a blend runs through 2 to {MAX_BLEND_SECTIONS} cross-sections, not {run}"))); + } + for c in crown { + if !c.is_finite() || !(-0.9..=3.0).contains(&c) { + return Err(KernelError::Invalid("a blend's crown must be between -0.9 and 3".into())); + } + } + for (i, section) in sections.iter().enumerate() { + if section.len() != samples { + return Err(KernelError::Invalid(format!( + "every cross-section of a blend must be sampled at the same {samples} stations (section {i} has {})", + section.len() + ))); + } + for (s, f) in section.iter().enumerate() { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) { + return Err(KernelError::Invalid(format!("the frame at section {i}, station {s} is not finite"))); + } + if f.leave.normalized().is_none() { + return Err(KernelError::Invalid(format!("the leave direction at section {i}, station {s} is zero"))); + } + } + } + // The face runs from one section to the next: where two of them meet at a station there is + // no room for a blend between them. + for i in 0..run - 1 { + for s in 0..samples { + if !(point(sections, i, s)?.dist(point(sections, i + 1, s)?) > 1e-9) { + return Err(KernelError::Invalid(format!("sections {} and {} meet at station {s}: there is no room for a blend", i + 1, i + 2))); + } + } + } + // The ends hold what their orders ask for. An interior section would like all four + // conditions (position, tangent, curvature, change of curvature); where no fourth station + // is there to take the third difference over it keeps the three its neighbours can give, + // and its joint is C² instead of C³. + let mut conditions = vec![2usize; run]; + if let Some(slot) = conditions.first_mut() { + *slot = orders[0].conditions(); + } + if let Some(slot) = conditions.last_mut() { + *slot = orders[1].conditions(); + } + for i in 1..run.saturating_sub(1) { + if let Some(slot) = conditions.get_mut(i) { + *slot = if third_difference_available(i, run) { 4 } else { 3 }; + } + } + // The run direction's degree is the largest `c + c′ − 1` of a span. Four conditions a + // section is at most what a section can carry, so this fits today; should a caller ever + // ask for more, the interior joints give up conditions rather than the blend failing. + let mut degree = span_degree(&conditions); + while degree > MAX_SPLINE_DEGREE { + let next = (1..run.saturating_sub(1)) + .filter(|&i| conditions.get(i).is_some_and(|&c| c > 1)) + .max_by_key(|&i| conditions.get(i).copied().unwrap_or(0)); + let Some(i) = next else { break }; + if let Some(c) = conditions.get_mut(i) { + *c = c.saturating_sub(1); + } + degree = span_degree(&conditions); + } + if degree == 0 || degree > MAX_SPLINE_DEGREE { + return Err(KernelError::Failed("loft blend: the requested continuity needs a degree above the kernel's".into())); + } + Ok(Blend { sections, crown, samples, run, step: 1.0 / (run - 1) as f64, degree, conditions }) + } + + /// What the face has to do at section `i`: the run direction's derivatives at every station. + fn shape(&self, i: usize) -> Result { + let samples = self.samples; + let p: Vec = (0..samples).map(|s| point(self.sections, i, s)).collect::>>()?; + let conditions = self.conditions.get(i).copied().unwrap_or(1); + let mut d1 = vec![Vec3::ZERO; samples]; + let mut d2 = vec![Vec3::ZERO; samples]; + let mut d3 = (conditions >= 4).then(|| vec![Vec3::ZERO; samples]); + if i == 0 { + for s in 0..samples { + let f = frame(self.sections, 0, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + // The speed a bridge gives a boundary: a third of the station's chord to the + // section it leaves for, the crown, over this span's parameter interval. + let speed = f.p.dist(point(self.sections, 1, s)?) / 3.0 * (1.0 + self.crown[0]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = leave * speed; + *v2 = f.bend * (speed * speed); + } + } + } else if i + 1 == self.run { + for s in 0..samples { + let f = frame(self.sections, i, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + let speed = f.p.dist(point(self.sections, i - 1, s)?) / 3.0 * (1.0 + self.crown[1]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + // The run leaves the last section outwards; the caller's frames there point + // back into it, so the odd derivatives turn round. + *v1 = leave * -speed; + *v2 = f.bend * (speed * speed); + } + } + } else { + let h = self.step; + for s in 0..samples { + let before = point(self.sections, i - 1, s)?; + let here = point(self.sections, i, s)?; + let after = point(self.sections, i + 1, s)?; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = (after - before) * (1.0 / (2.0 * h)); + *v2 = (after - here * 2.0 + before) * (1.0 / (h * h)); + } + } + } + // The change of curvature: the caller's bend_change at an end, a third difference of the + // neighbouring sections at an interior one. + if let Some(third) = d3.as_mut() { + for s in 0..samples { + let (Some(&a), Some(&b)) = (d1.get(s), d2.get(s)) else { + return Err(KernelError::Failed("loft blend: a section's derivatives".into())); + }; + let value = if i == 0 { + let f = frame(self.sections, 0, s)?; + third_derivative(a, b, f.bend_change)? + } else if i + 1 == self.run { + let f = frame(self.sections, i, s)?; + -third_derivative(-a, b, f.bend_change)? + } else { + self.third_difference(i, s)? + }; + if let Some(slot) = third.get_mut(s) { + *slot = value; + } + } + } + Ok(Shape { p, d1, d2, d3 }) + } + + /// The third difference of the sections' stations at an interior section: the estimate of how + /// the curvature vector changes along the run. `i` is an interior section, and the neighbours + /// reach far enough for a stencil (see [`third_difference_available`]). + fn third_difference(&self, i: usize, s: usize) -> Result { + let h = self.step; + let cube = h * h * h; + let before = i.checked_sub(2); + let value = match (before, i + 2 < self.run) { + // Both sides: the symmetric five-point difference. + (Some(k), true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 2.0 + point(self.sections, i - 1, s)? * 2.0 + - point(self.sections, k, s)?) + * (1.0 / (2.0 * cube)) + } + // Only ahead: the forward four-point stencil at the second of them. + (None, true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i - 1, s)?) + * (1.0 / cube) + } + // Only behind: the same the other way round. + (Some(k), false) => { + (point(self.sections, k, s)? - point(self.sections, i - 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i + 1, s)?) + * (1.0 / cube) + } + (None, false) => return Err(KernelError::Failed("loft blend: no fourth station for a change of curvature".into())), + }; + if !value.is_finite() { + return Err(KernelError::Failed("loft blend: the sections' third difference is not finite".into())); + } + Ok(value) + } + + /// The Bézier rows of the span from section `j` to section `j + 1`: one control row per + /// condition at each end, elevated to the run direction's degree so the whole face is one + /// tensor surface. Row `k` of the span is control row `j·degree + k` of the face. + fn span(&self, j: usize, left: &Shape, right: &Shape) -> Result>> { + let (Some(&kl), Some(&kr)) = (self.conditions.get(j), self.conditions.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section with no conditions".into())); + }; + let m = kl.saturating_add(kr).saturating_sub(1); + if !(1..=MAX_SPLINE_DEGREE).contains(&m) { + return Err(KernelError::Failed("loft blend: a span's degree".into())); + } + let k = m as f64; + let delta = self.step; + let one = delta / k; + let two = if m >= 2 { delta * delta / (k * (k - 1.0)) } else { 0.0 }; + let three = if m >= 3 { delta * delta * delta / (k * (k - 1.0) * (k - 2.0)) } else { 0.0 }; + let mut out = vec![vec![Vec3::ZERO; self.samples]; m + 1]; + let missing = || KernelError::Failed("loft blend: a section's derivatives".into()); + for s in 0..self.samples { + // The near end: one row per condition, position first. + let p = *left.p.get(s).ok_or_else(missing)?; + put(&mut out, 0, s, p)?; + if kl >= 2 { + let d1 = *left.d1.get(s).ok_or_else(missing)?; + let q1 = p + d1 * one; + put(&mut out, 1, s, q1)?; + if kl >= 3 { + let d2 = *left.d2.get(s).ok_or_else(missing)?; + let q2 = d2 * two + q1 * 2.0 - p; + put(&mut out, 2, s, q2)?; + if kl >= 4 { + let d3 = left.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, 3, s, d3 * three + q2 * 3.0 - q1 * 3.0 + p)?; + } + } + } + // The far end, its own conditions read backwards. + let e = *right.p.get(s).ok_or_else(missing)?; + put(&mut out, m, s, e)?; + if kr >= 2 { + let d1 = *right.d1.get(s).ok_or_else(missing)?; + let r1 = e - d1 * one; + put(&mut out, m - 1, s, r1)?; + if kr >= 3 { + let d2 = *right.d2.get(s).ok_or_else(missing)?; + let r2 = d2 * two + r1 * 2.0 - e; + put(&mut out, m - 2, s, r2)?; + if kr >= 4 { + let d3 = right.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, m - 3, s, r2 * 3.0 - r1 * 3.0 + e - d3 * three)?; + } + } + } + } + // A span whose two ends carry fewer conditions than the run direction's degree is + // elevated: the same curve, written in more control rows. + while out.len().saturating_sub(1) < self.degree { + out = elevate(&out); + } + Ok(out) + } + + /// The run direction's knots: clamped at both ends, repeated `degree` times at every interior + /// section — so the face interpolates each section and every span in between is one Bézier. + fn run_knots(&self) -> Vec { + let degree = self.degree; + let mut knots = vec![0.0; degree + 1]; + for i in 1..self.run.saturating_sub(1) { + knots.extend(std::iter::repeat_n(i as f64 / (self.run - 1) as f64, degree)); + } + knots.extend(std::iter::repeat_n(1.0, degree + 1)); + knots + } + + /// The face's control net: u runs with the sections, v along each section's curve. + fn net(&self) -> Result { + let shapes: Vec = (0..self.run).map(|i| self.shape(i)).collect::>>()?; + // One span per pair of neighbouring sections. The first row of a span is the last row of + // the one before it — the shared section — so only the first span writes it. + let mut rows: Vec> = Vec::with_capacity((self.run - 1) * self.degree + 1); + for j in 0..self.run.saturating_sub(1) { + let (Some(left), Some(right)) = (shapes.get(j), shapes.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section's shape".into())); + }; + for row in self.span(j, left, right)?.into_iter().skip(usize::from(j > 0)) { + rows.push(row); + } + } + let count_u = rows.len(); + // Along each section's curve the rows are the interpolation through that section's + // stations: the same parameters and degree for every row, so every row has one knot + // vector. Rows are solved in batches of `ROWS_PER_SOLVE` (three coordinates each), which + // shares one elimination between them. + let params: Vec = (0..self.samples).map(|s| s as f64 / (self.samples - 1) as f64).collect(); + let degree_v = 3.min(self.samples - 1); + let mut ctrl = vec![HPoint::ZERO; count_u * self.samples]; + let mut knots_v: Option> = None; + for (batch, chunk) in rows.chunks(ROWS_PER_SOLVE).enumerate() { + const WIDTH: usize = 3 * ROWS_PER_SOLVE; + let mut points: Vec<[f64; WIDTH]> = Vec::with_capacity(self.samples); + for s in 0..self.samples { + let mut q = [0.0f64; WIDTH]; + for (r, row) in chunk.iter().enumerate() { + if let Some(p) = row.get(s) { + for (slot, value) in q.iter_mut().skip(3 * r).zip([p.x, p.y, p.z]) { + *slot = value; + } + } + } + points.push(q); + } + let curve = CurveN::::interpolate(¶ms, &points, degree_v).map_err(|e| KernelError::Failed(format!("loft blend: {e}")))?; + if knots_v.is_none() { + knots_v = Some(curve.knots.clone()); + } + for (s, c) in curve.ctrl.iter().enumerate() { + for (r, xyz) in c.chunks(3).enumerate() { + let i = batch * ROWS_PER_SOLVE + r; + // Coordinates past the net's last row are this batch's own padding: their + // controls are not part of the surface. + if i >= count_u { + break; + } + if let [x, y, z] = *xyz { + let Some(slot) = s.checked_mul(count_u).and_then(|base| base.checked_add(i)).and_then(|k| ctrl.get_mut(k)) else { + continue; + }; + *slot = HPoint::weighted(Vec3::new(x, y, z), 1.0); + } + } + } + } + let knots_v = knots_v.ok_or_else(|| KernelError::Failed("loft blend: no row could be interpolated".into()))?; + NurbsSurface::from_homogeneous([self.degree, degree_v], [self.run_knots(), knots_v], ctrl) + .map_err(|e| KernelError::Failed(format!("loft blend: {e}"))) + } +} + +/// The frame at section `i`, station `s`. +fn frame(sections: &[Vec], i: usize, s: usize) -> Result<&Frame> { + sections.get(i).and_then(|section| section.get(s)).ok_or_else(|| KernelError::Failed("loft blend: a section is missing a station".into())) +} + +/// The point of section `i` at station `s`. +fn point(sections: &[Vec], i: usize, s: usize) -> Result { + frame(sections, i, s).map(|f| f.p) +} + +/// `rows[k][s] = v`, an error when the net has no such row or station. +fn put(rows: &mut [Vec], k: usize, s: usize, v: Vec3) -> Result<()> { + match rows.get_mut(k).and_then(|row| row.get_mut(s)) { + Some(slot) => { + *slot = v; + Ok(()) + } + None => Err(KernelError::Failed("loft blend: the control net is too small".into())), + } +} + +/// One exact degree elevation of a Bézier control net of rows: the same curve, one control row +/// more. +fn elevate(rows: &[Vec]) -> Vec> { + let degree = rows.len().saturating_sub(1); + let up = degree as f64 + 1.0; + let mut out: Vec> = Vec::with_capacity(rows.len() + 1); + if let Some(first) = rows.first() { + out.push(first.clone()); + } + for i in 1..=degree { + if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) { + let a = i as f64 / up; + out.push(before.iter().zip(here).map(|(x, y)| *x * a + *y * (1.0 - a)).collect()); + } + } + if let Some(last) = rows.last() { + out.push(last.clone()); + } + out +} + +/// Whether a third difference for the interior section `i` can be taken from the sections around +/// it: a stencil needs a station on one side of it and two on the other, and with three sections +/// there is none. +fn third_difference_available(i: usize, run: usize) -> bool { + i >= 2 || i + 2 < run +} + +/// The degree a family of sections asks for: the largest `c + c′ − 1` over its spans. +fn span_degree(conditions: &[usize]) -> usize { + let mut degree = 0; + for w in conditions.windows(2) { + if let [a, b] = w { + degree = degree.max(a.saturating_add(*b).saturating_sub(1)); + } + } + degree +} + +/// The third derivative a curve needs at a boundary so that the arclength derivative of its +/// curvature vector is `target`, from its first two derivatives there. Only the normal-plane part +/// of the third derivative can hold that condition; the rest is a reparameterisation of the +/// cross-section. This is `bridge`'s own construction, kept here so the bridge core stays +/// untouched. +fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result { + let sigma = d1.len(); + if !(sigma > 1e-12) || !sigma.is_finite() { + return Err(KernelError::Failed("loft blend: no room for the curvature condition".into())); + } + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let r = tp * (-d2.dot(t)) - t * d2.dot(tp); + let x = (target * (sigma.powi(5)) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2))) * (1.0 / (sigma * sigma)); + if !x.is_finite() { + return Err(KernelError::Failed("loft blend: the change of curvature is not finite".into())); + } + Ok(x - t * x.dot(t)) +} + +#[cfg(test)] +#[path = "loft_blend_tests.rs"] +mod tests; diff --git a/crates/kernel/src/loft_blend_tests.rs b/crates/kernel/src/loft_blend_tests.rs new file mode 100644 index 00000000..df60b675 --- /dev/null +++ b/crates/kernel/src/loft_blend_tests.rs @@ -0,0 +1,460 @@ +//! Tests of the multi-section blend: analytic cross-sections given as frames, and the surface +//! measured back off the face it built (a grid of points, five-point differences along the run). + +use super::*; +use crate::body::from_p3; +use crate::bridge::{bridge, sample_grid}; +use truck_modeling as mt; +use truck_modeling::ParametricSurface; + +/// The blend's surface: it is one face. +fn surface_of(body: &Body) -> mt::Surface { + body.solid.face_iter().next().unwrap().surface() +} + +/// Position and the run direction's first three derivatives at `(t, v)` of the face, read with the +/// five-point differences `bridge_tests` reads a cross-section with. The samples run inwards from +/// the section (so nothing is asked of the face outside its own domain); `backwards` reads with +/// the parameter falling and turns the odd derivatives round, so every value is a derivative in +/// the run direction `u`. +fn run_derivs(body: &Body, t: f64, v: f64, backwards: bool, h: f64) -> (Vec3, Vec3, Vec3, Vec3) { + let face = body.solid.face_iter().next().unwrap(); + let surf = face.surface(); + let dir = if backwards { -1.0 } else { 1.0 }; + let q: Vec = (0..5).map(|k| from_p3(surf.subs(t + dir * k as f64 * h, v))).collect(); + let d1 = (q[0] * -25.0 + q[1] * 48.0 + q[2] * -36.0 + q[3] * 16.0 + q[4] * -3.0) * (dir / (12.0 * h)); + let d2 = (q[0] * 35.0 + q[1] * -104.0 + q[2] * 114.0 + q[3] * -56.0 + q[4] * 11.0) * (1.0 / (12.0 * h * h)); + let d3 = (q[0] * -5.0 + q[1] * 18.0 + q[2] * -24.0 + q[3] * 14.0 + q[4] * -3.0) * (dir / (2.0 * h * h * h)); + (q[0], d1, d2, d3) +} + +/// The geometric shape of a curve from its first three derivatives: the unit tangent, the +/// curvature vector and the arclength derivative of that curvature vector (`bridge`'s own +/// reading, kept local to the test). +fn curve_shape(d1: Vec3, d2: Vec3, d3: Vec3) -> (Vec3, Vec3, Vec3) { + let sigma = d1.len(); + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp); + let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2)); + (t, nn * (1.0 / (sigma * sigma)), kappa * (1.0 / sigma.powi(5))) +} + +/// The angle between two vectors, in degrees. +fn angle_deg(a: Vec3, b: Vec3) -> f64 { + (a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// A circle of radius `r` at height `z` about the run (the z axis), sampled at `n` stations. Each +/// frame leaves along `leave`; its `bend` is given per station as a function of the station's +/// outward radial direction (a support surface curving in the leave direction), and its change of +/// curvature is `change`. +fn circle_section(r: f64, z: f64, n: usize, leave: Vec3, bend: impl Fn(Vec3) -> Vec3, change: Vec3) -> Vec { + (0..n) + .map(|s| { + let a = std::f64::consts::TAU * s as f64 / n as f64; + let p = Vec3::new(r * a.cos(), r * a.sin(), z); + Frame { p, leave, bend: bend(Vec3::new(p.x / r, p.y / r, 0.0)), bend_change: change } + }) + .collect() +} + +/// `count` straight cross-sections along x, `10` apart in z and rising in order: the run is +z. +/// Every frame leaves along +z except the last one, which leaves back into the blend along −z as +/// a bridge's far boundary does. `bend` and `change` are the same at every station, and `bend` is +/// perpendicular to the run (the only shape a support surface can have along a direction it does +/// not bend around). +fn line_sections(count: usize, n: usize, bend: Vec3, change: Vec3) -> Vec> { + (0..count) + .map(|i| { + let leave = if i + 1 == count { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + (0..n).map(|s| Frame { p: Vec3::new(10.0 * s as f64 / (n - 1) as f64, 0.0, 10.0 * i as f64), leave, bend, bend_change: change }).collect() + }) + .collect() +} + +/// A point at arclength `s` along the perimeter of the rounded rectangle of half-width `a`, +/// half-height `b` and corner radius `r` (in the z = 0 plane, counter-clockwise from the right +/// edge's lower end). +fn rounded_rect_at(a: f64, b: f64, r: f64, s: f64) -> Vec3 { + let (sa, sb) = ((a - r).max(0.0), (b - r).max(0.0)); + let quarter = std::f64::consts::FRAC_PI_2; + let segs = [2.0 * sb, quarter * r, 2.0 * sa, quarter * r, 2.0 * sb, quarter * r, 2.0 * sa, quarter * r]; + let mut left = s; + let mut k = 0; + while k + 1 < segs.len() && left > segs[k] { + left -= segs[k]; + k += 1; + } + let f = if segs[k] > 0.0 { left / segs[k] } else { 0.0 }; + let arc = |centre: Vec3, from: f64| { + let ang = from + quarter * f; + Vec3::new(centre.x + r * ang.cos(), centre.y + r * ang.sin(), 0.0) + }; + match k { + 0 => Vec3::new(a, -sb + 2.0 * sb * f, 0.0), + 1 => arc(Vec3::new(sa, sb, 0.0), 0.0), + 2 => Vec3::new(sa - 2.0 * sa * f, b, 0.0), + 3 => arc(Vec3::new(-sa, sb, 0.0), quarter), + 4 => Vec3::new(-a, sb - 2.0 * sb * f, 0.0), + 5 => arc(Vec3::new(-sa, -sb, 0.0), std::f64::consts::PI), + 6 => Vec3::new(-sa + 2.0 * sa * f, -b, 0.0), + _ => arc(Vec3::new(sa, -sb, 0.0), 3.0 * quarter), + } +} + +/// A rounded rectangle at height `z`, sampled at `n` even arclength steps around it: a section +/// that is not a scaled copy of the circles it can sit between, with a plan support (no bend). +fn rounded_rect_section(a: f64, b: f64, r: f64, z: f64, n: usize) -> Vec { + let total = 4.0 * ((a - r).max(0.0) + (b - r).max(0.0)) + 2.0 * std::f64::consts::PI * r; + (0..n) + .map(|s| { + let p = rounded_rect_at(a, b, r, total * s as f64 / n as f64); + Frame { p: Vec3::new(p.x, p.y, z), leave: Vec3::new(0.0, 0.0, 1.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// Two parallel circular cylinders of radius `r` 20 apart in y, leaving towards each other along +/// ∓y, each bending up towards its own axis and with a curvature that does not change as it goes +/// round (`bridge_tests`'s own case, for the two-section comparison). +fn valley_between_cylinders(count: usize, r: f64) -> (Vec, Vec) { + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..count { + let y = 10.0 * i as f64 / (count - 1) as f64; + a.push(Frame { + p: Vec3::new(0.0, y, 0.0), + leave: Vec3::new(0.0, 1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0), + }); + b.push(Frame { + p: Vec3::new(0.0, y + 20.0, 0.0), + leave: Vec3::new(0.0, -1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0), + }); + } + (a, b) +} + +/// Three circles of different sizes at three heights, the middle one smaller: a shape that +/// changes as the face runs up it. +fn three_circles(n: usize) -> Vec> { + vec![ + // The first section is the equator of a sphere of its own radius: the support bends + // inwards by 1/r along the run, and its curvature does not change as it goes round. + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |radial| radial * -0.1, Vec3::new(0.0, 0.0, -0.01)), + // The middle section's own shape data are ignored: the blend reads it from its neighbours. + circle_section(8.0, 10.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + // The last section's frames leave back into the blend, along −z. + circle_section(12.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ] +} + +/// Every station of every section is on the face, within the tolerance a blend promises. +fn assert_runs_through(body: &Body, sections: &[Vec], n: usize) { + let grid = sample_grid(body, sections.len(), n).unwrap(); + for (k, section) in sections.iter().enumerate() { + for (s, f) in section.iter().enumerate() { + assert!(grid[k][s].dist(f.p) < 1e-9, "section {k} station {s}: {:?} vs {:?}", grid[k][s], f.p); + } + } +} + +#[test] +fn three_sections_run_through_every_station() { + let n = 9; + let sections = three_circles(n); + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The ends hold what their frames ask for: the leave direction is the tangent, and the + // support's own curvature (1/10 inwards on the sphere, nothing on the plane) is the face's. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + // The stations run round the circle at even angles. + let angle = std::f64::consts::TAU * s as f64 / n as f64; + let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); + let (_, d1, d2, d3) = run_derivs(&body, 0.0, v, false, 1e-3); + let (t, bend, _) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "the face leaves along +z: {t:?}"); + assert!(bend.dist(radial * -0.1) < 1e-4, "the face leaves the sphere with its curvature: {bend:?}"); + // The last section's frames leave back into the blend, so read the shape that way round. + let (_, d1, d2, d3) = run_derivs(&body, 1.0, v, true, 1e-3); + let (t, bend, _) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "and arrives along the far leave: {t:?}"); + assert!(bend.len() < 1e-3, "the last section's support is flat: {bend:?}"); + } + + // Three sections: the one interior joint has no fourth station to take a third difference + // over, so it holds position, tangent and curvature — C² — and both sides agree. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + let (from_left, left1, left2, left3) = run_derivs(&body, 0.5, v, false, 1e-4); + let (from_right, right1, right2, right3) = run_derivs(&body, 0.5, v, true, 1e-4); + assert!(from_left.dist(from_right) < 1e-9, "the joint is on the middle section"); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "the tangent is continuous: {left1:?} vs {right1:?}"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "the curvature is continuous: {left2:?} vs {right2:?}"); + // But the change of curvature is not: a three-point stencil carries no third difference, + // so the joint is C² and not C³, and the difference stays whatever the step size is. + assert!((left3 - right3).len() > 0.05 * (1.0 + left3.len()), "a C² joint is not C³: {left3:?} vs {right3:?}"); + // What it holds is the sections' own finite differences along the run: the secant of the + // two neighbours for the tangent, their second difference for the curvature. + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-7 * (1.0 + secant.len()), "the tangent is the neighbours' secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-4 * (1.0 + second.len()), "the curvature is the neighbours' second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn the_ends_hold_the_shape_their_frames_ask_for() { + let stations = 7; + let bend = Vec3::new(0.0, 0.2, 0.0); + let change = Vec3::new(0.0, 0.5, 0.0); + let sections = line_sections(3, stations, bend, change); + let z = |s: f64| s * 10.0; + + // G1 holds the tangent and nothing more. + let g1 = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g1, 0.0, v, false, 1e-3); + let (t, _, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G1 blend leaves along leave: {t:?}"); + } + + // G2 holds the tangent and the support's curvature, at both ends. + let g2 = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g2, 0.0, v, false, 1e-3); + let (t, k, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G2 blend leaves along leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G2 blend holds the support's curvature: {k:?} vs {bend:?}"); + let (p, d1, d2, d3) = run_derivs(&g2, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9, "the face ends on the last section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "reading back, it arrives along the far leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "the far end holds the curvature too: {k:?}"); + assert!(c.len() < 1e3); + } + // A G2 blend leaves the change of curvature free: moving the frames' own value does not move + // the face at the first section, while a G3 blend follows it. + let (_, d1, d2, d3) = run_derivs(&g2, 0.0, 0.5, false, 1e-3); + let free = curve_shape(d1, d2, d3).2; + let moved = line_sections(3, stations, bend, change + Vec3::new(0.0, 1.0, 0.0)); + let g2_moved = loft_blend(&moved, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g2_moved, 0.0, 0.5, false, 1e-3); + assert!(curve_shape(d1, d2, d3).2.dist(free) < 1e-9, "a G2 blend does not read the far frame's change"); + + // G3 holds the change of curvature as well: its normal part is what a surface match can hold, + // and here the frame's own change is normal (along its curvature vector). + let g3 = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (_, d1, d2, d3) = run_derivs(&g3, 0.0, v, false, 1e-3); + let (t, k, c) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G3 blend still holds the curvature: {k:?}"); + assert!((c.y - change.y).abs() < 1e-3, "a G3 blend holds the change of curvature: {c:?} vs {change:?}"); + let (p, d1, d2, d3) = run_derivs(&g3, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3); + assert!((c.y - change.y).abs() < 1e-3, "and the far end holds it too: {c:?} vs {change:?}"); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9); + } + // With G3, moving the frames' own value does move the face there. + let g3_moved = loft_blend(&moved, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g3_moved, 0.0, 0.5, false, 1e-3); + assert!((curve_shape(d1, d2, d3).2.y - free.y).abs() > 0.5, "a G3 blend follows the frame's change"); +} + +#[test] +fn four_sections_join_at_c3() { + let n = 9; + let sections: Vec> = [(10.0, 0.0), (9.0, 10.0), (8.0, 20.0), (11.0, 30.0)] + .iter() + .enumerate() + .map(|(i, &(r, z))| { + let leave = if i == 3 { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + circle_section(r, z, n, leave, |_| Vec3::ZERO, Vec3::ZERO) + }) + .collect(); + let body = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // Every joint now has a fourth station for the third difference, so the two sides agree in + // the tangent, the curvature and its change: the joints are C³. + let h = 1.0 / 3.0; + for (i, s) in [(1usize, 0usize), (1, 4), (1, 8), (2, 0), (2, 4), (2, 8)] { + let t = i as f64 / 3.0; + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, left3) = run_derivs(&body, t, v, true, 1e-4); + let (_, right1, right2, right3) = run_derivs(&body, t, v, false, 1e-4); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "joint {i} station {s}: the tangent agrees"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "joint {i} station {s}: the curvature agrees"); + // The change of curvature agrees too, as far as finite differences can tell: its + // mismatch falls away with the step size, so the joint is C³, not just C². + assert!( + (left3 - right3).len() < 2e-2 * (1.0 + left3.len()), + "joint {i} station {s}: the change of curvature agrees: {left3:?} vs {right3:?}" + ); + // What they hold are the sections' own differences along the run. + let p = |k: usize| sections[k][s].p; + let d1 = (p(i + 1) - p(i - 1)) * (1.0 / (2.0 * h)); + let d2 = (p(i + 1) - p(i) * 2.0 + p(i - 1)) * (1.0 / (h * h)); + let d3 = if i == 1 { + (p(0) * -1.0 + p(1) * 3.0 - p(2) * 3.0 + p(3)) * (1.0 / (h * h * h)) + } else { + (p(0) - p(1) * 3.0 + p(2) * 3.0 - p(3)) * (1.0 / (h * h * h)) + }; + assert!((left1 - d1).len() < 1e-7 * (1.0 + d1.len()), "the tangent is the secant: {left1:?} vs {d1:?}"); + assert!((left2 - d2).len() < 1e-4 * (1.0 + d2.len()), "the curvature is the second difference: {left2:?} vs {d2:?}"); + assert!((left3 - d3).len() < 2e-2 * (1.0 + d3.len()), "the change of curvature is the third difference: {left3:?} vs {d3:?}"); + } +} + +#[test] +fn a_shape_that_is_not_a_scaled_copy_still_runs_through() { + let n = 17; + let sections = vec![ + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + rounded_rect_section(9.0, 7.0, 2.5, 10.0, n), + circle_section(8.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ]; + let body = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The interior joint is still smooth to C² and holds the neighbours' own estimates, whatever + // the sections' shapes are. + for s in [0usize, 4, 8, 12, 16] { + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, _) = run_derivs(&body, 0.5, v, false, 2e-4); + let (_, right1, right2, _) = run_derivs(&body, 0.5, v, true, 2e-4); + assert!((left1 - right1).len() < 1e-6 * (1.0 + left1.len()), "station {s}: the tangent is continuous"); + assert!((left2 - right2).len() < 1e-6 * (1.0 + left2.len()), "station {s}: the curvature is continuous"); + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-6 * (1.0 + secant.len()), "station {s}: the tangent is the secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-6 * (1.0 + second.len()), "station {s}: the curvature is the second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn a_two_section_blend_is_the_bridge() { + let (a, b) = valley_between_cylinders(9, 10.0); + let made = loft_blend(&[a.clone(), b.clone()], [Continuity::G2, Continuity::G2], [0.0, 0.0], 9).unwrap(); + let other = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap(); + // The blend runs with the sections, the bridge crosses from one boundary to the other: the + // same face, its two parameters the other way round. + for (run, station) in [(0.0, 0.0), (0.25, 0.3), (0.5, 0.7), (0.75, 0.1), (1.0, 1.0)] { + let x = from_p3(surface_of(&made).subs(run, station)); + let y = from_p3(surface_of(&other).subs(station, run)); + assert!(x.dist(y) < 1e-9, "run {run} station {station}: {x:?} vs {y:?}"); + } + let grid = sample_grid(&other, 9, 7).unwrap(); + for (row, frame) in grid.iter().zip(&a) { + assert!(row[0].dist(frame.p) < 1e-9); + } +} + +#[test] +fn crown_shapes_the_middle_without_moving_the_joints() { + let n = 9; + let sections = three_circles(n); + let mut heights = Vec::new(); + let mut joints = Vec::new(); + for crown in [-0.5, 0.0, 1.0, 2.0] { + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [crown, crown], n).unwrap(); + // The interior joint holds the sections' own estimates whatever the crown does: those do + // not depend on it. + let (p, d1, d2, _) = run_derivs(&body, 0.5, 0.5, false, 2e-4); + assert!(p.dist(sections[1][4].p) < 1e-9, "the crown left the joint where the sections put it"); + joints.push((d1, d2)); + // Halfway up the first span is where a fuller crown reaches further. + heights.push(from_p3(surface_of(&body).subs(0.25, 0.5)).z); + assert_runs_through(&body, §ions, n); + } + for (d1, d2) in &joints { + let (base1, base2) = joints[0]; + assert!((*d1 - base1).len() < 1e-4 * (1.0 + base1.len()), "the crown left the joint's tangent alone: {d1:?}"); + assert!((*d2 - base2).len() < 1e-4 * (1.0 + base2.len()), "the crown left the joint's curvature alone: {d2:?}"); + } + let steps: Vec = heights.windows(2).map(|w| w[1] - w[0]).collect(); + assert!(steps.iter().all(|d| d.abs() > 1e-6 && d.signum() == steps[0].signum()), "a fuller crown moves the middle one way: {heights:?}"); +} + +#[test] +fn the_blend_refuses_what_it_cannot_hold() { + let n = 5; + let orders = [Continuity::G1, Continuity::G1]; + let good = three_circles(n); + assert!(loft_blend(&good, orders, [0.0, 0.0], n).is_ok()); + + // Fewer than two sections, and more than the cap. + assert!(loft_blend(&good[..1], orders, [0.0, 0.0], n).is_err(), "one section is no blend"); + let many: Vec> = + (0..=MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 3, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + assert!(loft_blend(&many, orders, [0.0, 0.0], 3).is_err(), "more than {MAX_BLEND_SECTIONS} sections"); + + // Station counts that do not match each other, or the count asked for. + let mut unequal = good.clone(); + unequal[1] = unequal[1][..n - 1].to_vec(); + assert!(loft_blend(&unequal, orders, [0.0, 0.0], n).is_err(), "unequal station counts"); + assert!(loft_blend(&good, orders, [0.0, 0.0], n + 1).is_err(), "the samples must match the sections"); + for samples in [0usize, 1, MAX_BLEND_STATIONS + 1] { + assert!(loft_blend(&good, orders, [0.0, 0.0], samples).is_err(), "samples {samples}"); + } + + // A crown outside its range, and one that is not a number. + for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] { + assert!(loft_blend(&good, orders, [crown, crown], n).is_err(), "crown {crown}"); + } + assert!(loft_blend(&good, orders, [0.0, f64::NAN], n).is_err()); + + // A frame that is not a number, wherever it is not, and a leave direction with no direction. + for pick in 0..4 { + let mut bad = good.clone(); + let frame = bad[1][2]; + bad[1][2] = match pick { + 0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frame }, + 1 => Frame { leave: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frame }, + 2 => Frame { bend: Vec3::new(0.0, f64::NAN, 0.0), ..frame }, + _ => Frame { bend_change: Vec3::new(0.0, 0.0, f64::NAN), ..frame }, + }; + assert!(loft_blend(&bad, orders, [0.0, 0.0], n).is_err(), "a non-finite frame {pick}"); + } + let mut no_leave = good.clone(); + no_leave[1][2].leave = Vec3::ZERO; + assert!(loft_blend(&no_leave, orders, [0.0, 0.0], n).is_err(), "a leave direction with no direction"); + + // Two sections that meet at a station: no room for a blend. + let mut meeting = good.clone(); + meeting[1] = meeting[0].clone(); + assert!(loft_blend(&meeting, orders, [0.0, 0.0], n).is_err(), "sections that meet"); + let mut touching = good.clone(); + touching[1][2].p = touching[0][2].p; + assert!(loft_blend(&touching, orders, [0.0, 0.0], n).is_err(), "a station where the sections meet"); + + // Every order from G0 to G3 builds, alone and together, and so does the largest blend. + for (near, far) in [ + (Continuity::G0, Continuity::G0), + (Continuity::G1, Continuity::G0), + (Continuity::G0, Continuity::G3), + (Continuity::G3, Continuity::G3), + (Continuity::G2, Continuity::G3), + ] { + let body = loft_blend(&good, [near, far], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, &good, n); + } + let full: Vec> = + (0..MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 5, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + let body = loft_blend(&full, [Continuity::G3, Continuity::G3], [0.0, 0.0], 5).unwrap(); + assert_runs_through(&body, &full, 5); +} diff --git a/crates/kernel/src/native/metadata.rs b/crates/kernel/src/native/metadata.rs index 1dccec7f..f87bd4ea 100644 --- a/crates/kernel/src/native/metadata.rs +++ b/crates/kernel/src/native/metadata.rs @@ -32,7 +32,8 @@ fn segment(s: &Seg2) -> Result { Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w }, Seg2::Bezier { n, p } => { let count = usize::from(n) + 1; - if !(5..=8).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) + { return Err(invalid("unsupported certificate Bezier layout")); } w::Segment::Bezier { points: p.get(..count).ok_or_else(|| invalid("Bezier count"))?.iter().copied().map(point2).collect() } @@ -46,10 +47,10 @@ fn load_segment(s: w::Segment) -> Result { w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) }, w::Segment::Conic { points: [a, apex, b], middle_weight } => Seg2::Conic { a: p2(a), apex: p2(apex), b: p2(b), w: middle_weight }, w::Segment::Bezier { points } => { - if !(5..=8).contains(&points.len()) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&points.len()) { return Err(invalid("certificate Bezier degree")); } - let mut p = [Vec2::ZERO; 8]; + let mut p = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; let n = (points.len() - 1) as u8; for (slot, value) in p.iter_mut().zip(points) { *slot = p2(value); @@ -324,7 +325,7 @@ mod tests { for value in sources { assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap()); } - assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; 9] }).is_err()); + assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; solidcraft_geom::MAX_BEZIER_CTRL + 1] }).is_err()); } #[test] diff --git a/crates/render/src/curve_display.rs b/crates/render/src/curve_display.rs index 08bbebf5..cd5a63cc 100644 --- a/crates/render/src/curve_display.rs +++ b/crates/render/src/curve_display.rs @@ -115,7 +115,9 @@ fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec>, H)> { Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]], Seg2::Cubic { p0, p1, p2, p3 } => vec![vec![h(p0, 1.0), h(p1, 1.0), h(p2, 1.0), h(p3, 1.0)]], Seg2::Conic { a, apex, b, w } if w > 0.0 => vec![vec![h(a, 1.0), h(apex, w), h(b, 1.0)]], - Seg2::Bezier { n, p } if (1..=7).contains(&n) => vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()], + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()] + } Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 => { diff --git a/crates/render/src/curve_display_tests.rs b/crates/render/src/curve_display_tests.rs index a3f45505..8aa62e69 100644 --- a/crates/render/src/curve_display_tests.rs +++ b/crates/render/src/curve_display_tests.rs @@ -27,7 +27,7 @@ fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath) #[test] fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() { - let mut degree7 = [Vec2::ZERO; 8]; + let mut degree7 = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, p) in degree7.iter_mut().enumerate() { *p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 }); } diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index 885b8786..66a7fb27 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -153,7 +153,7 @@ pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 { return p.first().copied().unwrap_or_default(); } if control { - return bspline_at(p, degree.clamp(1, 7) as usize, t); + return bspline_at(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize, t); } let spans = fit_spans(p); let n = spans.len().max(1); @@ -292,7 +292,7 @@ pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec { return p.to_vec(); } if control { - return bspline_polyline(p, degree.clamp(1, 7) as usize); + return bspline_polyline(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize); } let mut out = Vec::new(); for (p0, p1, t0, t1) in fit_spans(p) { diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs index 20adc640..6529cd06 100644 --- a/crates/sketch/src/freeform_offset.rs +++ b/crates/sketch/src/freeform_offset.rs @@ -137,7 +137,9 @@ impl H { let (points, weights) = match *seg { Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]) + } Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), _ => return Err(bad("unsupported exact free-form offset piece")), }; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 8a8c274a..819a4d3a 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -256,7 +256,7 @@ impl LinkGeom { (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) - && (1..=7).contains(degree) + && (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) } } } diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 823a17a3..f6651e71 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -1,6 +1,6 @@ use std::f64::consts::{FRAC_PI_2, PI}; -use solidcraft_geom::Vec2; +use solidcraft_geom::{Seg2, Vec2}; use crate::*; @@ -921,3 +921,49 @@ fn projected_boundary_keeps_its_pieces_when_only_stations_go() { assert!(sk.update_link(&j, &smaller).unwrap()); assert_eq!(sk.link_curves(&j).len(), 4); } + +#[test] +fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { + // A G4 blend curve is one degree-9 Bézier; a sketch keeps it as a control spline whose exact + // piece is that Bézier, and profile measurement must keep using the curve, not its chords. + let mut sk = Sketch::new(); + let bump = + [v(0.0, 0.0), v(1.5, 4.0), v(3.0, 7.0), v(5.0, 9.5), v(7.5, 11.0), v(12.5, 11.0), v(15.0, 9.5), v(17.0, 7.0), v(18.5, 4.0), v(20.0, 0.0)]; + let pts: Vec = bump.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); + let s = sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 9, periodic: false }, None).unwrap(); + let pieces = sk.exact_pieces(s); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + let Seg2::Bezier { n, p } = pieces[0] else { panic!("{:?}", pieces[0]) }; + assert_eq!(n, 9, "the highest degree a sketch piece can carry"); + for (i, q) in bump.iter().enumerate() { + assert!(p[i].dist(*q) < 1e-12, "control {i}"); + } + // Evaluation and the exact piece agree at the spline's real degree (a degree clamp would show + // here), and a line's own exact piece still comes back as a line. + let exact = pieces[0]; + for k in 0..=10 { + let t = k as f64 / 10.0; + assert!(crate::curves::spline_point(&bump, true, 9, t).dist(exact.point_at(t)) < 1e-9, "t={t}"); + } + let l = sk.add_line(v(-5.0, 0.0), v(-2.0, 1.0), None, None, None).unwrap(); + assert!(matches!(sk.exact_pieces(l).as_slice(), [Seg2::Line { .. }])); + // Close the loop: the region's area is the shoelace integral of the exact pieces, which a fine + // sampling of the piece reproduces (a chord approximation would miss the bump's sagitta). + sk.add_line(v(20.0, 0.0), v(0.0, 0.0), Some(pts[9]), Some(pts[0]), None).unwrap(); + let ps = find_profiles(&sk); + assert_eq!(ps.len(), 1, "{ps:?}"); + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let (a, b) = (exact.point_at(t0), exact.point_at(t1)); + sampled += 0.5 * a.cross(b); + } + sampled -= 0.5 * v(20.0, 0.0).cross(v(0.0, 0.0)); + assert!((ps[0].area - sampled.abs()).abs() < 1e-5, "{} vs {sampled}", ps[0].area); + assert!((ps[0].area - 100.0).abs() > 1e-3, "the region is not its chord: {}", ps[0].area); + // A degree above the cap is still refused. + let mut high: Vec = pts.clone(); + high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); + assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 7694ad36..74266488 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -621,7 +621,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K ], ) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, .. } => { (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()]) } FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e5d72eea..0b176461 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -682,10 +682,33 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + // Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the + // colour of its worst verdict. + for seam in &s.continuity { + let col = continuity_color(seam.verdict); + for w in seam.points.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + sc.line(a.to_f32(), b.to_f32(), col, 2.6, true); + } + } + } sc } +/// The colour of a measured seam: green within every tolerance, yellow where the faces only +/// meet at an angle (over G1), red where they do not meet at all or the curvature jumps across +/// them (over G2, the defect no chord tolerance shows). The greens, yellows and reds are the +/// ones the shader's own analyses use (`assets/shaders/viewport.wgsl`). +fn continuity_color(verdict: solidcraft_engine::kernel::SeamVerdict) -> [u8; 4] { + use solidcraft_engine::kernel::SeamVerdict as V; + match verdict { + V::Smooth => [51, 191, 77, 255], + V::Sharp => [242, 204, 38, 255], + V::CurvatureJump | V::Gap => [217, 56, 51, 255], + } +} + /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let tk = Tokens::get(); diff --git a/docs/screenshots/class-a-blend.png b/docs/screenshots/class-a-blend.png new file mode 100644 index 00000000..59991f0e Binary files /dev/null and b/docs/screenshots/class-a-blend.png differ diff --git a/docs/screenshots/class-a-crown.png b/docs/screenshots/class-a-crown.png new file mode 100644 index 00000000..f38d6edd Binary files /dev/null and b/docs/screenshots/class-a-crown.png differ