Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 06:26:27 -04:00
1 parent 3126962cd4
commit bbd4f29ebb
43 files changed
+4753 -132

No files matched your search

+2
View File
@@ -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_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.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 | | 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/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/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 | | 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 |
+1 -4
View File
@@ -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 | | 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 | | 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 | | 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 | | 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 | | 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 | — | | 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 | | 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 |
+47
View File
@@ -563,6 +563,10 @@ pub enum FeatureKind {
/// What to do when the radius is too large for the geometry around the edges. /// What to do when the radius is too large for the geometry around the edges.
#[serde(default, skip_serializing_if = "FilletFit::is_exact")] #[serde(default, skip_serializing_if = "FilletFit::is_exact")]
fit: FilletFit, 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 { Chamfer {
edges: Vec<Position>, edges: Vec<Position>,
@@ -1311,6 +1315,10 @@ pub enum FeatureKind {
/// sketch curves: walls only, no caps. /// sketch curves: walls only, no caps.
SurfaceLoft { SurfaceLoft {
sections: Vec<SheetSection>, sections: Vec<SheetSection>,
#[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 /// 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; /// 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. /// How Extend continues a surface past its edges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -1734,6 +1759,28 @@ pub struct SheetSection {
pub profiles: Option<ProfileSel>, pub profiles: Option<ProfileSel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>, pub curves: Vec<String>,
/// 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<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Position>,
}
/// 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. /// Coil cross-sections.
+9
View File
@@ -4551,8 +4551,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}; };
st.blend_edges.insert(f.id, edges.clone()); st.blend_edges.insert(f.id, edges.clone());
let input = mb.traced(); 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. // The blended body, and the lineage of what the last blend started from.
let (nb, from) = match &f.kind { 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::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::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => { FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
@@ -5238,6 +5246,7 @@ mod chain_tests {
body: None, body: None,
style: crate::FilletStyle::Constant, style: crate::FilletStyle::Constant,
fit: Default::default(), fit: Default::default(),
corner: Default::default(),
}, },
None, None,
) )
+123 -2
View File
@@ -3,7 +3,7 @@
//! `surface_offset`, `surface_ruled`, `surface_untrim`). //! `surface_offset`, `surface_ruled`, `surface_untrim`).
use super::*; use super::*;
use crate::{RuledCorners, RuledMode}; use crate::{RuledCorners, RuledMode, SheetSection};
use solidcraft_geom::Seg2; use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> { pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
@@ -313,7 +313,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
push_sheet(st, f, traced); push_sheet(st, f, traced);
Ok(()) 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 secs = Vec::with_capacity(sections.len());
let mut sketches = Vec::with_capacity(sections.len()); let mut sketches = Vec::with_capacity(sections.len());
for sec in sections { for sec in sections {
@@ -553,3 +559,118 @@ fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
} }
Ok(chains) 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<solidcraft_geom::Seg2>),
Body(String, Vec<solidcraft_geom::Vec3>),
}
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<Vec2> = 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<kernel::Frame> {
let mut pts: Vec<Vec2> = 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
}
@@ -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.
+2
View File
@@ -113,6 +113,7 @@ fn box_fillet_cut_timeline() {
body: None, body: None,
style: Default::default(), style: Default::default(),
fit: Default::default(), fit: Default::default(),
corner: Default::default(),
}, },
None, None,
) )
@@ -183,6 +184,7 @@ fn errors_are_reported_not_fatal() {
body: None, body: None,
style: Default::default(), style: Default::default(),
fit: Default::default(), fit: Default::default(),
corner: Default::default(),
}, },
None, None,
) )
+1
View File
@@ -50,6 +50,7 @@ impl Walk for crate::document::Operation {}
impl Walk for solidcraft_kernel::Continuity {} impl Walk for solidcraft_kernel::Continuity {}
impl Walk for crate::document::Direction {} impl Walk for crate::document::Direction {}
impl Walk for crate::document::FilletFit {} impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::FilletCorner {}
impl Walk for crate::document::MeshData {} impl Walk for crate::document::MeshData {}
impl Walk for crate::document::RevolveConvention {} impl Walk for crate::document::RevolveConvention {}
impl Walk for crate::document::CoilSection {} impl Walk for crate::document::CoilSection {}
+67 -2
View File
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY") .at("SOLID", "MODIFY")
.icon("fillet") .icon("fillet")
.key("F") .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.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.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) CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -1051,10 +1051,26 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
if !fit.is_exact() && !style.is_constant() { if !fit.is_exact() && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius")); 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( let mut out = add_feature(
s, s,
p, 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. // The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64) if let Some(id) = out.get("feature").and_then(Value::as_u64)
@@ -2042,6 +2058,55 @@ mod thin_extrude_tests;
#[cfg(test)] #[cfg(test)]
#[path = "extrude_convention_tests.rs"] #[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests; 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)] #[cfg(test)]
#[path = "fillet_connected_rounds_tests.rs"] #[path = "fillet_connected_rounds_tests.rs"]
mod fillet_connected_rounds_tests; mod fillet_connected_rounds_tests;
+480 -5
View File
@@ -88,7 +88,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY") .at("SKETCH", "MODIFY")
.icon("blend") .icon("blend")
.enabled(in_sketch) .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) CommandSpec::new("sketch.centerline", "Centerline", centerline)
.at("SKETCH", "CREATE") .at("SKETCH", "CREATE")
.icon("centerline") .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))) 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<usize> {
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<Vec<Vec2>> {
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<Value> { fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.blend_curve"; let cmd = "sketch.blend_curve";
let order = blend_order(p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| { 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 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"))?; 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<Value> {
return Err(bad(cmd, "the ends must differ")); return Err(bad(cmd, "the ends must differ"));
} }
let first_new = sk.points.len(); let first_new = sk.points.len();
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; let (sp, cons) = if order <= 1 {
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; let c1 = sk.add_point(qa + ta * (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 c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; 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<usize> = 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); solve_new_only(sk, first_new);
Ok((ids_of(sk, &[sp]), cons)) 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<String> {
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<Vec2> {
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<Vec2> {
let c: Vec<Vec2> = 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<Vec2>, usize, Seg2) {
let sk = sketch(s);
let ci = sk.curve_index(id).unwrap();
let ctrl: Vec<Vec2> = 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}");
}
}
+8 -3
View File
@@ -5,6 +5,11 @@ use solidcraft_sketch::{Shape, intersections};
use crate::{Result, params::bad}; 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 { 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) (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<Ve
let (points, weights): (Vec<Vec2>, Vec<f64>) = match *seg { let (points, weights): (Vec<Vec2>, Vec<f64>) = match *seg {
Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), 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::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")), _ => return Err(bad("sketch.extend", "invalid exact curve piece")),
}; };
if points.iter().any(|p| !p.is_finite()) { if points.iter().any(|p| !p.is_finite()) {
@@ -195,8 +200,8 @@ mod tests {
elevated.push(y[n - 1]); elevated.push(y[n - 1]);
y = elevated; y = elevated;
} }
let mut points = [Vec2::ZERO; 8]; let mut points = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL];
for (i, p) in points.iter_mut().enumerate() { for (i, p) in points.iter_mut().take(8).enumerate() {
*p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]);
} }
let target = Seg2::Bezier { n: 7, p: points }; let target = Seg2::Bezier { n: 7, p: points };
+110 -4
View File
@@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.loft", "Surface Loft", surface_loft) CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE") .at("SURFACE", "CREATE")
.icon("surface_loft") .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) CommandSpec::new("surface.offset", "Offset Surface", offset)
.at("SURFACE", "CREATE") .at("SURFACE", "CREATE")
.icon("surface_offset") .icon("surface_offset")
@@ -369,15 +369,57 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
} }
let mut sections = Vec::with_capacity(given.len()); let mut sections = Vec::with_capacity(given.len());
for sec in given { 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<solidcraft_geom::Vec3> =
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 sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?;
let curves = string_list(sec, "curves"); let curves = string_list(sec, "curves");
if curves.len() > 1000 { if curves.len() > 1000 {
return Err(bad(cmd, "at most 1000 curves a section")); return Err(bad(cmd, "at most 1000 curves a section"));
} }
let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None }; 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<Continuity> {
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<f64> {
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<Value> { fn offset(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1212,7 +1254,7 @@ mod tests {
/// as it was. /// as it was.
#[test] #[test]
fn planned_commands_refuse_and_leave_the_design_alone() { fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.fillet", "inspect.continuity"]; let planned = ["surface.fillet"];
let mut s = Session::default(); let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len(); 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", "iterations": 0, "tolerance": 1.0})).is_err());
assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 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}");
}
}
} }
+346 -8
View File
@@ -5,8 +5,19 @@ use serde_json::{Value, json};
use solidcraft_geom::Vec3; use solidcraft_geom::Vec3;
use super::CommandSpec; use super::CommandSpec;
use crate::params::{bad, bool_, num, vec3}; use crate::params::{bad, bool_, num, str_, vec3};
use crate::{Result, Session, SurfaceAnalysis}; 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] = &[ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra) CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
@@ -34,11 +45,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("curvature_map") .icon("curvature_map")
.noundo() .noundo()
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), .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") CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
.at("SURFACE", "INSPECT") "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
.icon("continuity") a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
.noundo() gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \
.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"), 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<Value> { fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
@@ -94,12 +108,214 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
set(s, p, SurfaceAnalysis::Curvature { radius }) 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<Value> {
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<usize>)> = 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<ContinuitySeam> = 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<f64> {
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<Vec<kernel::EdgePick>> {
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<kernel::SeamVerdict> {
[
(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)] #[cfg(test)]
mod tests { mod tests {
use serde_json::json; use serde_json::{Value, json};
use crate::{Session, SurfaceAnalysis}; 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<Value> {
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] #[test]
fn surface_analyses_are_view_state() { fn surface_analyses_are_view_state() {
let mut s = Session::default(); 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.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 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", &params).is_err(), "accepted {params}");
}
let many: Vec<Value> = (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");
}
} }
+15
View File
@@ -239,6 +239,9 @@ pub struct Session {
pub section: Option<(Vec3, Vec3)>, pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map). /// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>, pub analysis: Option<SurfaceAnalysis>,
/// 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<ContinuitySeam>,
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an /// 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`). /// undo step. A design brings its saved visibility when it opens (`Session::new`).
pub visibility: Visibility, pub visibility: Visibility,
@@ -301,6 +304,16 @@ pub enum SurfaceAnalysis {
Access { dir: Vec3 }, 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<Vec3>,
/// The seam's worst verdict along its length.
pub verdict: kernel::SeamVerdict,
}
impl SurfaceAnalysis { impl SurfaceAnalysis {
/// How a picture shades the faces for this analysis. /// How a picture shades the faces for this analysis.
pub fn mode(self) -> render::AnalysisMode { pub fn mode(self) -> render::AnalysisMode {
@@ -356,6 +369,7 @@ impl Session {
sketch_clipboard: None, sketch_clipboard: None,
section: None, section: None,
analysis: None, analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(), checkpoints: Vec::new(),
next_checkpoint: 1, next_checkpoint: 1,
saved_visibility: visibility.clone(), saved_visibility: visibility.clone(),
@@ -823,6 +837,7 @@ impl Session {
sketch_clipboard: self.sketch_clipboard.clone(), sketch_clipboard: self.sketch_clipboard.clone(),
section: None, section: None,
analysis: None, analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(), checkpoints: Vec::new(),
next_checkpoint: 1, next_checkpoint: 1,
visibility: self.visibility.clone(), visibility: self.visibility.clone(),
+53
View File
@@ -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<Vec<Vec2>> {
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 }
}
+3 -1
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod arcfit; mod arcfit;
pub mod blend;
pub mod brep; pub mod brep;
pub mod bspline; pub mod bspline;
pub mod compare; pub mod compare;
@@ -23,9 +24,10 @@ pub mod tspline;
mod vec; mod vec;
pub use arcfit::MAX_FIT_PIECES; 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 mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
pub use plane::Plane; 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}; pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests (mm). /// Geometric tolerance for coincidence tests (mm).
+131 -10
View File
@@ -38,26 +38,35 @@ pub enum Seg2 {
b: Vec2, b: Vec2,
w: f64, w: f64,
}, },
/// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree /// Bézier of degree `n` (4…[`MAX_BEZIER_DEGREE`]) over `p[0..=n]` (the rest unused): pieces of
/// splines. /// higher-degree splines.
Bezier { Bezier {
n: u8, n: u8,
p: [Vec2; 8], p: [Vec2; MAX_BEZIER_CTRL],
}, },
} }
/// Highest degree of [`Seg2::Bezier`]. /// Highest degree of [`Seg2::Bezier`]: a degree-9 piece is the single Bézier a G4 blend curve
pub const MAX_BEZIER_DEGREE: usize = 7; /// (`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 /// Control points a [`Seg2::Bezier`] carries: its highest degree plus one (the unused tail is
/// quadratic becomes a conic of weight 1. /// 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<Seg2> { pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
Some(match *pts { Some(match *pts {
[a, b] => Seg2::Line { a, b }, [a, b] => Seg2::Line { a, b },
[a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 }, [a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 },
[p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 }, [p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 },
_ if pts.len() <= MAX_BEZIER_DEGREE + 1 => { _ if (2..=MAX_BEZIER_CTRL).contains(&pts.len()) => {
let mut p = [Vec2::ZERO; 8]; let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (d, s) in p.iter_mut().zip(pts) { for (d, s) in p.iter_mut().zip(pts) {
*d = *s; *d = *s;
} }
@@ -86,6 +95,38 @@ fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec<Vec2>, Vec<Vec2>) {
(left.last().copied().unwrap_or(Vec2::ZERO), left, right) (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<Vec<Vec2>> {
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). /// Gauss–Legendre nodes and weights on [0, 1] (5 points).
const GL5: [(f64, f64); 5] = [ const GL5: [(f64, f64); 5] = [
(0.046_910_077_030_668, 0.118_463_442_528_095), (0.046_910_077_030_668, 0.118_463_442_528_095),
@@ -96,7 +137,7 @@ const GL5: [(f64, f64); 5] = [
]; ];
impl Seg2 { 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] &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). /// 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<Vec<Vec2>> {
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 { pub fn start(&self) -> Vec2 {
match *self { match *self {
Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a, Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a,
+158
View File
@@ -560,3 +560,161 @@ mod close_pairs {
assert!(budget < 10_000_000, "work was counted"); 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<Vec2> {
let c: Vec<Vec2> = 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<Vec2> = (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<Vec2> = 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);
}
}
+1 -1
View File
@@ -667,7 +667,7 @@ mod tests {
assert!(degree_2(&conic(2.0), Some(vec![1.0, 2.0, 1.0]))); 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]))); 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. // 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() { for (i, s) in q.iter_mut().enumerate() {
*s = Vec2::new(i as f64, (i * i) as f64); *s = Vec2::new(i as f64, (i * i) as f64);
} }
+2
View File
@@ -864,6 +864,7 @@ mod tests {
body: None, body: None,
style: Default::default(), style: Default::default(),
fit: Default::default(), fit: Default::default(),
corner: Default::default(),
}, },
None, None,
) )
@@ -948,6 +949,7 @@ mod tests {
body: None, body: None,
style: Default::default(), style: Default::default(),
fit: Default::default(), fit: Default::default(),
corner: Default::default(),
}, },
None, None,
) )
+24 -6
View File
@@ -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. /// [`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<Vec<mt::EdgeID>> { pub(crate) fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option<Vec<mt::EdgeID>> {
use mt::{BoundedCurve, ParametricCurve}; use mt::{BoundedCurve, ParametricCurve};
let all = Body::unique_edges(solid); 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). // 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<Body> {
// trimmed by the face. Refused, the refusal above stands. // 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)) .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. // 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). /// 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 /// Blend the edges together, rebuilding the corners where they meet on faces of any surface
/// (see [`crate::curveblend::corner_blend`]). /// (see [`crate::curveblend::corner_blend`]); with `g2`, the corners where three blends meet are
fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> { /// 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<Body> {
let counts = record::counts(&[body]); let counts = record::counts(&[body]);
let cur = crate::boolean_record::healed(body)?; let cur = crate::boolean_record::healed(body)?;
let size = cur.size(); 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() { if ids.len() != edges.len() {
return Err(KernelError::Invalid("no edge near a selected point".into())); 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)) 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> {
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. /// The step of the radius search: three significant digits of the requested radius.
fn radius_step(radius: f64) -> f64 { fn radius_step(radius: f64) -> f64 {
10f64.powf(radius.log10().floor() - 3.0) 10f64.powf(radius.log10().floor() - 3.0)
@@ -2191,7 +2209,7 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
Ok(cur) Ok(cur)
}; };
// Chamfers meeting at corners on curved faces: all at once. // 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). /// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).
+71 -16
View File
@@ -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 /// 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> { pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result<Body> {
body.require_brep("fillet")?; body.require_brep("fillet")?;
spline.check()?; spline.check()?;
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
guard("variable fillet", || { 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). // 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() }; let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() };
build(body, &s, &edge, &law) 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<Body> {
// 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 /// 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). /// and that shell's faces, the frame at the edge, and its ends (front first).
struct EdgeSetup<'a> { struct EdgeSetup<'a> {
@@ -570,8 +586,11 @@ pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body,
Ok((cur, tapers)) Ok((cur, tapers))
} }
/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend /// Chord-length fillet: each edge rounded so that the blend is `chord` wide across, from contact
/// is `chord` wide across; edges with the same face angle share a radius. /// 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> { pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
body.require_brep("fillet")?; body.require_brep("fillet")?;
if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) { if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) {
@@ -580,19 +599,44 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
if edges.is_empty() || edges.len() > 1000 { if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into())); return Err(KernelError::Invalid("select 1…1000 edges".into()));
} }
// Radius per edge from its face angle. let found: Vec<mt::Edge> = edges
let mut groups: Vec<(f64, Vec<Vec3>)> = Vec::new(); .iter()
for p in edges { .map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}"))))
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; .collect::<Result<_>>()?;
let shell = // The exact radius of a straight edge between planar faces; None where the rolling ball has to
body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?; // take the edge (a curved edge, or one between curved faces).
let faces: Vec<&mt::Face> = shell.face_iter().collect(); let exact_radius = |e: &mt::Edge| -> Option<f64> {
let fr = edge_frame(&faces, &e)?; if !matches!(e.curve(), mt::Curve::Line(_)) {
let r = chord_radius(chord, fr.phi); return None;
match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) {
Some((_, v)) => v.push(*p),
None => groups.push((r, vec![*p])),
} }
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<Vec3>)> = Vec::new();
let mut rolled: Vec<Vec<Vec3>> = Vec::new();
let mut done: Vec<mt::EdgeID> = 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]`. // Each step's result carries its history over `[body]`.
let counts = crate::record::counts(&[body]); let counts = crate::record::counts(&[body]);
@@ -600,6 +644,17 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
for (r, pts) in groups { for (r, pts) in groups {
cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts); 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<mt::Edge> = 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) Ok(cur)
} }
+497
View File
@@ -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<Vec3>,
}
/// The seam diagnostics of one body.
#[derive(Clone, Debug, Serialize)]
pub struct ContinuityReport {
pub seams: Vec<SeamReport>,
/// 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<Vec<usize>> {
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<usize> = 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<ContinuityReport> {
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<mt::Edge>, Vec<Vec<usize>>)> {
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<usize>], 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<usize> {
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<ContinuityReport> {
let (list, adjacent) = topology(body)?;
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut selected: Vec<usize> = 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<SeamReport> {
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<SeamWorst>, 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<f64> {
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());
}
}
+501
View File
@@ -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<Frame>], apex: Vec3) -> Result<Vec<mt::Face>> {
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<Vec3> {
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<Option<Vec3>> {
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<Frame>]) -> Result<(Vec<Frame>, Vec<usize>, 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<Frame> = Vec::new();
let mut starts: Vec<usize> = 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<Vec3>]) -> Result<Vec<Frame>> {
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<Vec<Vec3>> {
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<Vec<Vec3>> {
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<Vec3> = 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<f64> = (0..=(nu + ALONG) as i64).map(|i| i as f64).collect();
let mut ctrl: Vec<HPoint> = Vec::with_capacity(nu * (ACROSS + 1));
for j in 0..=ACROSS {
let points: Vec<Vec3> = 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<Vec3> { 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<Frame>], apex: Vec3) -> Result<Vec<mt::Face>> {
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::<f64>() / frames.len().max(1) as f64;
let reach = (frames.iter().map(|f| f.p.dist(apex)).sum::<f64>() / 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::<Result<_>>()?;
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<HPoint> = (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<f64> = starts.iter().map(|i| *i as f64 + ALONG as f64).collect();
cuts.push(m + ALONG as f64);
let mut verts: Vec<mt::Vertex> = 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<mt::Edge> = 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<Frame>], 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<Vec3> = (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::<Vec<_>>()
);
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}");
}
}
+154
View File
@@ -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<Frame> {
(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<Vec<Frame>>, 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<Frame>]) -> Vec<Vec<Frame>> {
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<Vec<Frame>> = sides.iter().map(|s| s[..3].to_vec()).collect();
assert!(corner_g2_patch(&thin, apex).is_err());
let fat: Vec<Vec<Frame>> = 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<Vec<Frame>> = 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<Vec<Frame>> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect();
assert!(corner_g2_patch(&on_apex, apex).is_err());
}
+435 -56
View File
@@ -1,10 +1,17 @@
//! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces //! 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 //! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball rolls along
//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its //! the edge touching both sides; its centre traces the spine and its contacts the curves where
//! contacts the curves where the blend meets each side. The blend face is a rational B-spline //! the blend meets each side. The blend face is a rational B-spline through the circular
//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed //! cross-sections (exact arcs at the samples), the side faces are trimmed back to the contact
//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are //! curves, and seams that met the edge (lines, or arcs of an end face) are shortened to the
//! shortened to the contacts. //! 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; use std::collections::HashMap;
@@ -12,6 +19,7 @@ use solidcraft_geom::Vec3;
use truck_modeling::{self as mt, builder}; use truck_modeling::{self as mt, builder};
use crate::blend::too_large; use crate::blend::too_large;
use crate::blend_variable::RadiusSpline;
use crate::body::{Solid, from_p3, p3}; use crate::body::{Solid, from_p3, p3};
use crate::record::Rebuild; use crate::record::Rebuild;
use crate::{KernelError, Result}; use crate::{KernelError, Result};
@@ -40,21 +48,60 @@ const TANGENT_RETRIES: usize = 8;
const MAX_STEP_STRETCH: f64 = 3.0; 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. /// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last.
const KNIFE_STEPS: usize = 40; 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 { struct Fit {
surface: mt::Surface, surface: mt::Surface,
contacts: [mt::Curve; 2], contacts: [mt::Curve; 2],
/// Parameter range belonging to this edge (the fit includes neighbouring sections). /// Parameter range belonging to this edge (the fit includes neighbouring sections).
range: (f64, f64), 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 { struct Guide {
sections: Vec<Section>, sections: Vec<Section>,
/// 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<f64>,
lengths: Vec<f64>, lengths: Vec<f64>,
/// 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 { 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<Section>) -> Result<Self> { fn new(sections: Vec<Section>) -> Result<Self> {
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<Section>, law: Vec<f64>) -> Result<Self> {
if sections.len() != law.len() {
return Err(unsupported("degenerate rolling-ball spine"));
}
let mut lengths = vec![0.0]; let mut lengths = vec![0.0];
let mut total = 0.0; let mut total = 0.0;
for pair in sections.windows(2) { for pair in sections.windows(2) {
@@ -65,10 +112,16 @@ impl Guide {
if !total.is_finite() || total <= 1e-12 { if !total.is_finite() || total <= 1e-12 {
return Err(unsupported("degenerate rolling-ball spine")); 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)> { 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) { if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
return None; return None;
} }
@@ -81,7 +134,8 @@ impl Guide {
return None; return None;
} }
let fraction = (distance - lo) / span; 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 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()))?; 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. /// 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. // 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; let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6;
if !(inside(*t1) && inside(*t2)) { 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))); 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 (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o));
let v_t = rt.ok_or_else(fail)?; 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; 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 { 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). // How far the strip's point (u, v) is outside the end face (negative: inside the body).
let outside = |u: f64, v: f64| -> Option<f64> { let outside = |u: f64, v: f64| -> Option<f64> {
@@ -599,6 +653,93 @@ fn solve_from(
None 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<Extra<'_>>,
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<Extra<'_>>,
) -> 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 /// 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 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`. /// 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) .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. /// 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]> { 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); 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; let m = (s.q1 + s.q2) * 0.5;
return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]); 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 (a, b) = (s.q1 - s.c, s.q2 - s.c);
let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos(); let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos();
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) { 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<mt::Vertex>, b: Option
.unwrap_or(end); .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"))?; 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) { 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) Ok(t)
}; };
let (ta, tb) = (at(&a, lo)?, at(&b, hi)?); let (ta, tb) = (at(&a, lo)?, at(&b, hi)?);
let margin = (hi - lo) * 1e-9; let margin = (hi - lo) * 1e-9;
if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 { 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 { if tb < hi {
let _ = curve.cut(tb); let _ = curve.cut(tb);
@@ -851,16 +1003,100 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option
mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}"))) 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<f64> {
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<Vec<f64>> {
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<f64> {
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 /// 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). /// `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)> { 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_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)> { 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())); 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) { if !(4..=MAX_SAMPLES).contains(&max_samples) {
return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into())); 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 // 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. // 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; 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 (convex, cusp) = if knife {
let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?; 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. // 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))) .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) .filter(|depth| depth.abs() > size * 1e-9)
.ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?; .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 { if n1.dot(n2) > 1.0 - 1e-6 {
return Err(unsupported("blending a smooth edge")); 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<Vec<f64>> = 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<f64> = 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 // 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. // radius less the blend's. Less than nothing is too large; nothing is a pivot.
let mut pivots: Vec<Option<Pivot>> = vec![None; n]; let mut pivots: Vec<Option<Pivot>> = 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 }; let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue };
for side in [1, 2] { 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 }; 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; continue;
} }
if rho < r - tol * 1e3 { 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 { if (rho - r).abs() > tol * 1e3 {
continue; 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()?)) Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?))
}; };
let mut vsec: Vec<Section> = Vec::with_capacity(nv); let mut vsec: Vec<Section> = Vec::with_capacity(nv);
// The radius each vertex section was solved at (the closed end of a law: zero).
let mut vradius: Vec<f64> = Vec::with_capacity(nv);
for k in 0..nv { for k in 0..nv {
let (p, t) = if k < n { let (p, t) = if k < n {
tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0) 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); 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 } }); 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; 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 { if at_end {
// A plane square to the edge holds the blend's end section. Any other end face: the // 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. // 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, _ => 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)); 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 // longer is (a wall past the round at a corner, which a ball rolling round it pokes
// through). // through).
let (a1, a2) = edge_sides(&[k.min(n - 1)]); 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. // 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) { if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
let q = if *side == 1 { s.q1 } else { s.q2 }; 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); vsec.push(s);
vradius.push(r);
} }
// Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near // 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. // at the chain's start, near to far at its end), far enough to cross the end face.
let mut beyond: Vec<(usize, Vec<Section>)> = Vec::new(); let mut beyond: Vec<(usize, Vec<Section>, Vec<f64>)> = Vec::new();
for (k, fi, p, t, tangent) in slanted.iter_mut() { for (k, fi, p, t, tangent) in slanted.iter_mut() {
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?; 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()))?; 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() { if cos < 0.15 && tangent.is_none() {
return Err(unsupported("an end face almost along the edge")); 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 reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos };
let out = if *k == 0 { -*t } else { *t }; 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 // 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). // 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 list = Vec::with_capacity(EXTENSION);
let mut laws = Vec::with_capacity(EXTENSION);
let mut reach = reach; let mut reach = reach;
// Past the end of a curved edge the planes square to its end tangent cut the ring of // 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 // 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. // 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 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 past = |sec: &Section| [sec.c, sec.q1, sec.q2].iter().all(|x| (*x - *p).dot(ng_out) > tol);
let mut fallback: Option<Vec<Section>> = 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<Vec<(f64, Section)>> = None;
for attempt in 0..TANGENT_RETRIES { for attempt in 0..TANGENT_RETRIES {
list.clear(); list.clear();
laws.clear();
let step = reach / EXTENSION as f64; let step = reach / EXTENSION as f64;
let sections = (1..=EXTENSION).map(|j| { let sections = (1..=EXTENSION).map(|j| {
let anchor = *p + out * (step * j as f64); let d = step * j as f64;
solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0) 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::<Option<Vec<_>>>() { match sections.collect::<Option<Vec<_>>>() {
Some(all) => { Some(all) => {
let smooth = tangent.is_some() let smooth = tangent.is_some()
|| std::iter::once(at.c).chain(all.iter().map(|s| s.c)).collect::<Vec<_>>().windows(2).all(|w| match w { || std::iter::once(at.c).chain(all.iter().map(|(_, s)| s.c)).collect::<Vec<_>>().windows(2).all(|w| match w {
[a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step, [a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step,
_ => true, _ => true,
}); });
if smooth { if smooth {
// A shortened run must still cross the end face; else the first, // A shortened run must still cross the end face; else the first,
// full-length run stands. // full-length run stands.
if fallback.is_none() || all.iter().any(past) { if fallback.is_none() || all.iter().any(|(_, s)| past(s)) {
list = all; list = all.iter().map(|(_, s)| *s).collect();
laws = all.iter().map(|(s, _)| *s).collect();
} }
break; 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 }; reach *= if tangent.is_some() { 0.5 } else { 0.75 };
} }
if list.is_empty() { 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 { if *k == 0 {
list.reverse(); list.reverse();
laws.reverse();
} }
beyond.push((*k, list)); beyond.push((*k, list, laws));
} }
// Uniform refinement preserves a common spacing across neighbouring patches. Work is // Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit. // 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))) Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1)))
}) })
.collect::<Result<_>>()?; .collect::<Result<_>>()?;
let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<Section> { 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 (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 (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 anchor = p + t * (start * (1.0 - along) + end * along);
let (a1, a2) = edge_sides(&[k]); 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 mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop { let (esec, fits) = loop {
@@ -1339,18 +1653,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
continue; continue;
} }
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; 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 { 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()))?); 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 { if *kv == k && k == 0 {
list.splice(0..0, ext.iter().copied()); 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 { } else if *kv == k + 1 && k + 1 == nv - 1 {
list.extend(ext.iter().copied()); 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 // Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine. // 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 { if along == 1.0 {
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into())); 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]); 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")) .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
}; };
let esec: Vec<Vec<Section>> = (0..n) let esec: Vec<Vec<Section>> = (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))?, (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))?, _ => 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)); fits.push(Some(fit));
} }
let mut accurate = true; 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 j in 0..samples - 1 {
for fraction in [0.25, 0.5, 0.75] { for fraction in [0.25, 0.5, 0.75] {
let along = (j as f64 + fraction) / (samples - 1) as f64; 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)?; let section = uniform_section(k, along)?;
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? { if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? {
accurate = false; accurate = false;
@@ -1515,7 +1857,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
&& n == 1 && n == 1
&& !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2) && !(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", || { crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); 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)); *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. // Along a pivot the planar side's contact is one point: one vertex at both its ends.
for (k, pv) in pivots.iter().enumerate() { for (k, pv) in pivots.iter().enumerate() {
let Some(pv) = pv else { continue }; 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<Option<mt::Edge>> = Vec::with_capacity(nv);
let mut split_arcs: HashMap<usize, Vec<mt::Edge>> = HashMap::new(); let mut split_arcs: HashMap<usize, Vec<mt::Edge>> = HashMap::new();
for (k, s) in vsec.iter().enumerate() { 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 (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
let e = if round { // A closed section has no arc to run between the contacts: the blend meets itself
let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r; // there, at the edge's end point.
builder::circle_arc(a, b, p3(m)) 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 { } else {
builder::line(a, b) Some(builder::line(a, b))
}; };
let e = match caps.iter().find(|c| c.0 == k) { 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, None => e,
}; };
if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) { 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<mt::Surface> { let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result<mt::Surface> {
use mt::{ParametricSurface3D, SearchNearestParameter}; use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; 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) { if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v); let nn = surface.normal(u, v);
let want = (s.c - mid) * sigma; 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)) .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)); .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) { 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)?); 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 (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)); 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 { 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 a = a.unwrap_or_else(|| seam.front().clone());
let b = b.unwrap_or_else(|| seam.back().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)) { if corners.iter().any(|k| split_arcs.contains_key(k)) {
let connectors: Vec<mt::Edge> = corners let connectors: Vec<mt::Edge> = corners
.iter() .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(); .collect();
let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?; let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?;
rec.face(&rebuilt, f); 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()); es.push(ne.clone());
} }
if let Some(k) = arrives { if let Some(k) = arrives {
let arc = arcs.get(k).ok_or_else(|| fail("arc"))?; // A closed section has no blend arc at the corner: the blend ends at
let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); // 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 to = if used_up { e.front() } else { ne.back() };
let corner = if to == a1 { let corner = if to == a1 {
arc.clone() 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. // Blend faces: each runs its side-1 contact the other way from side 1's face.
for (k, bf) in blends.iter().enumerate() { 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")); 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 f1i = side1.get(k).ok_or_else(|| fail("side"))?;
let f1 = faces.get(*f1i).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"))?; 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()) .map(|e| e.front() == chain_e.front())
.ok_or_else(|| fail("edge use"))?; .ok_or_else(|| fail("edge use"))?;
// Side 1 runs the contact forward (as the chain) when it ran the edge forward. // 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. // A pivot's planar contact is a point: three sides. A closed section has no arc: two.
let edges = if used_fwd { let edges: Vec<mt::Edge> = if used_fwd {
[e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] [e1.as_ref().map(|e| e.inverse()), ak.cloned(), e2.clone(), ak1.map(|e| e.inverse())]
} else { } 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() .into_iter()
.flatten() .flatten()
.collect::<Vec<_>>(); .collect();
// An arc the chamfer crossed is two lines through the crossing: the blend meets the
// shortened seam there.
let wire: mt::Wire = edges let wire: mt::Wire = edges
.into_iter() .into_iter()
.flat_map(|e| { .flat_map(|e| {
let split = [(k, ak), ((k + 1) % nv, ak1)] let split = [(k, ak), ((k + 1) % nv, ak1)]
.into_iter() .into_iter()
.filter_map(|(i, arc)| arc.map(|arc| (i, arc.clone())))
.find(|(_, arc)| arc.id() == e.id()) .find(|(_, arc)| arc.id() == e.id())
.and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts))); .and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts)));
match split { match split {
@@ -1861,8 +2233,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
#[path = "curvecorner.rs"] #[path = "curvecorner.rs"]
mod corner; 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; pub(crate) use corner::corner_blend;
#[cfg(test)] #[cfg(test)]
#[path = "curveblend_tests.rs"] #[path = "curveblend_tests.rs"]
mod tests; mod tests;
#[cfg(test)]
#[path = "curveblend_law_tests.rs"]
mod law_tests;
+315
View File
@@ -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<mt::Face> {
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<mt::Edge> {
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<f64> {
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<Vec3> {
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::<f64>()
};
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<f64>| 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());
}
+84
View File
@@ -25,6 +25,7 @@ use super::{
}; };
use crate::blend::{too_large, vtx}; use crate::blend::{too_large, vtx};
use crate::body::{Solid, from_p3, p3, v3}; use crate::body::{Solid, from_p3, p3, v3};
use crate::bridge::Frame;
use crate::record::Rebuild; use crate::record::Rebuild;
use crate::{KernelError, Result}; use crate::{KernelError, Result};
@@ -683,9 +684,65 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec<Owner>)],
Err(fail("a boundary does not close")) 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<f64> {
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<Vec<Frame>> {
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 /// 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`. /// 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)> { 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) { 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())); 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 out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z);
let side = out.any_perp(); let side = out.any_perp();
let (np, sp, back) = (c + side * r, c - side * r, c - out * r); 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 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( let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
meridian.oriented_curve(), meridian.oriented_curve(),
+1 -1
View File
@@ -442,7 +442,7 @@ fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis:
add(p)?; 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) { for p in p.iter().take(usize::from(n) + 1) {
add(*p)?; add(*p)?;
} }
+9 -1
View File
@@ -28,7 +28,9 @@ mod boundary;
mod bridge; mod bridge;
mod build; mod build;
mod clearance; mod clearance;
mod continuity;
mod coplanar; mod coplanar;
mod corner_g2;
mod curveblend; mod curveblend;
mod delete_face; mod delete_face;
#[cfg(test)] #[cfg(test)]
@@ -67,6 +69,7 @@ mod issue_871_tests;
#[cfg(test)] #[cfg(test)]
mod join_between_caps_tests; mod join_between_caps_tests;
mod lip; mod lip;
pub mod loft_blend;
mod loopblend; mod loopblend;
mod matchsurf; mod matchsurf;
mod measure; mod measure;
@@ -123,7 +126,7 @@ mod tspline;
mod untouched_faces_tests; mod untouched_faces_tests;
pub mod watch; 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_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; 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}; 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, PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
sweep_with, torus, 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 delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use fair::{ 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_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies; pub use iges_out::iges_export_bodies;
pub use lip::raise_band; pub use lip::raise_band;
pub use loft_blend::loft_blend;
pub use matchsurf::{match_surface, seam_continuity}; pub use matchsurf::{match_surface, seam_continuity};
pub use measure::{BodyMeasure, measure}; pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh; pub use mesh_convert::convert_prismatic_mesh;
+550
View File
@@ -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<Frame>], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result<Body> {
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<Vec<Frame>> {
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<Vec3>,
d1: Vec<Vec3>,
d2: Vec<Vec3>,
d3: Option<Vec<Vec3>>,
}
/// The blend's inputs, validated, with the conditions and the run direction's degree worked out.
struct Blend<'a> {
sections: &'a [Vec<Frame>],
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<usize>,
}
impl<'a> Blend<'a> {
fn new(sections: &'a [Vec<Frame>], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result<Blend<'a>> {
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<Shape> {
let samples = self.samples;
let p: Vec<Vec3> = (0..samples).map(|s| point(self.sections, i, s)).collect::<Result<Vec<_>>>()?;
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<Vec3> {
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<Vec<Vec<Vec3>>> {
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<f64> {
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<NurbsSurface> {
let shapes: Vec<Shape> = (0..self.run).map(|i| self.shape(i)).collect::<Result<Vec<_>>>()?;
// 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<Vec3>> = 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<f64> = (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<Vec<f64>> = 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::<WIDTH>::interpolate(&params, &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<Frame>], 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<Frame>], i: usize, s: usize) -> Result<Vec3> {
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<Vec3>], 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<Vec3>]) -> Vec<Vec<Vec3>> {
let degree = rows.len().saturating_sub(1);
let up = degree as f64 + 1.0;
let mut out: Vec<Vec<Vec3>> = 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<Vec3> {
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;
+460
View File
@@ -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<Vec3> = (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<Frame> {
(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<Vec<Frame>> {
(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<Frame> {
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<Frame>, Vec<Frame>) {
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<Frame>> {
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<Frame>], 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(&sections, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, 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(&sections, [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(&sections, [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(&sections, [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<Vec<Frame>> = [(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(&sections, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, 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(&sections, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, 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(&sections, [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, &sections, 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<f64> = 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<Vec<Frame>> =
(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<Vec<Frame>> =
(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);
}
+5 -4
View File
@@ -32,7 +32,8 @@ fn segment(s: &Seg2) -> Result<w::Segment> {
Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w }, Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w },
Seg2::Bezier { n, p } => { Seg2::Bezier { n, p } => {
let count = usize::from(n) + 1; 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")); 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() } 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<Seg2> {
w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) }, 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::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 } => { 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")); 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; let n = (points.len() - 1) as u8;
for (slot, value) in p.iter_mut().zip(points) { for (slot, value) in p.iter_mut().zip(points) {
*slot = p2(value); *slot = p2(value);
@@ -324,7 +325,7 @@ mod tests {
for value in sources { for value in sources {
assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap()); 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] #[test]
+3 -1
View File
@@ -115,7 +115,9 @@ fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec<Vec<H>>, H)> {
Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]], 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::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::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 } Seg2::Arc { center, radius, start, sweep }
if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 => if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 =>
{ {
+1 -1
View File
@@ -27,7 +27,7 @@ fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath)
#[test] #[test]
fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() { 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() { 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 }); *p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 });
} }
+2 -2
View File
@@ -153,7 +153,7 @@ pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 {
return p.first().copied().unwrap_or_default(); return p.first().copied().unwrap_or_default();
} }
if control { 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 spans = fit_spans(p);
let n = spans.len().max(1); let n = spans.len().max(1);
@@ -292,7 +292,7 @@ pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec<Vec2> {
return p.to_vec(); return p.to_vec();
} }
if control { 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(); let mut out = Vec::new();
for (p0, p1, t0, t1) in fit_spans(p) { for (p0, p1, t0, t1) in fit_spans(p) {
+3 -1
View File
@@ -137,7 +137,9 @@ impl H {
let (points, weights) = match *seg { let (points, weights) = match *seg {
Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), 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::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]), 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")), _ => return Err(bad("unsupported exact free-form offset piece")),
}; };
+1 -1
View File
@@ -256,7 +256,7 @@ impl LinkGeom {
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
&& pts.iter().all(ok) && pts.iter().all(ok)
&& (1..=7).contains(degree) && (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree)
} }
} }
} }
+47 -1
View File
@@ -1,6 +1,6 @@
use std::f64::consts::{FRAC_PI_2, PI}; use std::f64::consts::{FRAC_PI_2, PI};
use solidcraft_geom::Vec2; use solidcraft_geom::{Seg2, Vec2};
use crate::*; use crate::*;
@@ -921,3 +921,49 @@ fn projected_boundary_keeps_its_pieces_when_only_stations_go() {
assert!(sk.update_link(&j, &smaller).unwrap()); assert!(sk.update_link(&j, &smaller).unwrap());
assert_eq!(sk.link_curves(&j).len(), 4); 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<usize> = 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<usize> = 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());
}
+1 -1
View File
@@ -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()]) (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 } => { FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
+23
View File
@@ -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 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). /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
let tk = Tokens::get(); let tk = Tokens::get();
Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB