Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)
Thanks @midasdf.
This commit is contained in:
1 parent
3126962cd4
commit
bbd4f29ebb
43 files changed
+4753
-132
No files matched your search
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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", ¶ms).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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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, §ion, along, round, sigma, fit_tolerance)))? {
|
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, 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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¶ms, &points, degree_v).map_err(|e| KernelError::Failed(format!("loft blend: {e}")))?;
|
||||||
|
if knots_v.is_none() {
|
||||||
|
knots_v = Some(curve.knots.clone());
|
||||||
|
}
|
||||||
|
for (s, c) in curve.ctrl.iter().enumerate() {
|
||||||
|
for (r, xyz) in c.chunks(3).enumerate() {
|
||||||
|
let i = batch * ROWS_PER_SOLVE + r;
|
||||||
|
// Coordinates past the net's last row are this batch's own padding: their
|
||||||
|
// controls are not part of the surface.
|
||||||
|
if i >= count_u {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let [x, y, z] = *xyz {
|
||||||
|
let Some(slot) = s.checked_mul(count_u).and_then(|base| base.checked_add(i)).and_then(|k| ctrl.get_mut(k)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
*slot = HPoint::weighted(Vec3::new(x, y, z), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let knots_v = knots_v.ok_or_else(|| KernelError::Failed("loft blend: no row could be interpolated".into()))?;
|
||||||
|
NurbsSurface::from_homogeneous([self.degree, degree_v], [self.run_knots(), knots_v], ctrl)
|
||||||
|
.map_err(|e| KernelError::Failed(format!("loft blend: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame at section `i`, station `s`.
|
||||||
|
fn frame(sections: &[Vec<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;
|
||||||
@@ -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(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap();
|
||||||
|
assert_runs_through(&body, §ions, n);
|
||||||
|
|
||||||
|
// The ends hold what their frames ask for: the leave direction is the tangent, and the
|
||||||
|
// support's own curvature (1/10 inwards on the sphere, nothing on the plane) is the face's.
|
||||||
|
for s in [0usize, 4, 8] {
|
||||||
|
let v = s as f64 / (n - 1) as f64;
|
||||||
|
// The stations run round the circle at even angles.
|
||||||
|
let angle = std::f64::consts::TAU * s as f64 / n as f64;
|
||||||
|
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&body, 0.0, v, false, 1e-3);
|
||||||
|
let (t, bend, _) = curve_shape(d1, d2, d3);
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "the face leaves along +z: {t:?}");
|
||||||
|
assert!(bend.dist(radial * -0.1) < 1e-4, "the face leaves the sphere with its curvature: {bend:?}");
|
||||||
|
// The last section's frames leave back into the blend, so read the shape that way round.
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&body, 1.0, v, true, 1e-3);
|
||||||
|
let (t, bend, _) = curve_shape(-d1, d2, -d3);
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "and arrives along the far leave: {t:?}");
|
||||||
|
assert!(bend.len() < 1e-3, "the last section's support is flat: {bend:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three sections: the one interior joint has no fourth station to take a third difference
|
||||||
|
// over, so it holds position, tangent and curvature — C² — and both sides agree.
|
||||||
|
for s in [0usize, 4, 8] {
|
||||||
|
let v = s as f64 / (n - 1) as f64;
|
||||||
|
let (from_left, left1, left2, left3) = run_derivs(&body, 0.5, v, false, 1e-4);
|
||||||
|
let (from_right, right1, right2, right3) = run_derivs(&body, 0.5, v, true, 1e-4);
|
||||||
|
assert!(from_left.dist(from_right) < 1e-9, "the joint is on the middle section");
|
||||||
|
assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "the tangent is continuous: {left1:?} vs {right1:?}");
|
||||||
|
assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "the curvature is continuous: {left2:?} vs {right2:?}");
|
||||||
|
// But the change of curvature is not: a three-point stencil carries no third difference,
|
||||||
|
// so the joint is C² and not C³, and the difference stays whatever the step size is.
|
||||||
|
assert!((left3 - right3).len() > 0.05 * (1.0 + left3.len()), "a C² joint is not C³: {left3:?} vs {right3:?}");
|
||||||
|
// What it holds is the sections' own finite differences along the run: the secant of the
|
||||||
|
// two neighbours for the tangent, their second difference for the curvature.
|
||||||
|
let p = |k: usize| sections[k][s].p;
|
||||||
|
let secant = p(2) - p(0);
|
||||||
|
let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0;
|
||||||
|
assert!((left1 - secant).len() < 1e-7 * (1.0 + secant.len()), "the tangent is the neighbours' secant: {left1:?} vs {secant:?}");
|
||||||
|
assert!((left2 - second).len() < 1e-4 * (1.0 + second.len()), "the curvature is the neighbours' second difference: {left2:?} vs {second:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ends_hold_the_shape_their_frames_ask_for() {
|
||||||
|
let stations = 7;
|
||||||
|
let bend = Vec3::new(0.0, 0.2, 0.0);
|
||||||
|
let change = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
let sections = line_sections(3, stations, bend, change);
|
||||||
|
let z = |s: f64| s * 10.0;
|
||||||
|
|
||||||
|
// G1 holds the tangent and nothing more.
|
||||||
|
let g1 = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], stations).unwrap();
|
||||||
|
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
|
||||||
|
let (p, d1, d2, d3) = run_derivs(&g1, 0.0, v, false, 1e-3);
|
||||||
|
let (t, _, _) = curve_shape(d1, d2, d3);
|
||||||
|
assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}");
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G1 blend leaves along leave: {t:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// G2 holds the tangent and the support's curvature, at both ends.
|
||||||
|
let g2 = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap();
|
||||||
|
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
|
||||||
|
let (p, d1, d2, d3) = run_derivs(&g2, 0.0, v, false, 1e-3);
|
||||||
|
let (t, k, _) = curve_shape(d1, d2, d3);
|
||||||
|
assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}");
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G2 blend leaves along leave: {t:?}");
|
||||||
|
assert!(k.dist(bend) / bend.len() < 1e-3, "a G2 blend holds the support's curvature: {k:?} vs {bend:?}");
|
||||||
|
let (p, d1, d2, d3) = run_derivs(&g2, 1.0, v, true, 1e-3);
|
||||||
|
let (t, k, c) = curve_shape(-d1, d2, -d3);
|
||||||
|
assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9, "the face ends on the last section: {p:?}");
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "reading back, it arrives along the far leave: {t:?}");
|
||||||
|
assert!(k.dist(bend) / bend.len() < 1e-3, "the far end holds the curvature too: {k:?}");
|
||||||
|
assert!(c.len() < 1e3);
|
||||||
|
}
|
||||||
|
// A G2 blend leaves the change of curvature free: moving the frames' own value does not move
|
||||||
|
// the face at the first section, while a G3 blend follows it.
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&g2, 0.0, 0.5, false, 1e-3);
|
||||||
|
let free = curve_shape(d1, d2, d3).2;
|
||||||
|
let moved = line_sections(3, stations, bend, change + Vec3::new(0.0, 1.0, 0.0));
|
||||||
|
let g2_moved = loft_blend(&moved, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap();
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&g2_moved, 0.0, 0.5, false, 1e-3);
|
||||||
|
assert!(curve_shape(d1, d2, d3).2.dist(free) < 1e-9, "a G2 blend does not read the far frame's change");
|
||||||
|
|
||||||
|
// G3 holds the change of curvature as well: its normal part is what a surface match can hold,
|
||||||
|
// and here the frame's own change is normal (along its curvature vector).
|
||||||
|
let g3 = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap();
|
||||||
|
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&g3, 0.0, v, false, 1e-3);
|
||||||
|
let (t, k, c) = curve_shape(d1, d2, d3);
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01);
|
||||||
|
assert!(k.dist(bend) / bend.len() < 1e-3, "a G3 blend still holds the curvature: {k:?}");
|
||||||
|
assert!((c.y - change.y).abs() < 1e-3, "a G3 blend holds the change of curvature: {c:?} vs {change:?}");
|
||||||
|
let (p, d1, d2, d3) = run_derivs(&g3, 1.0, v, true, 1e-3);
|
||||||
|
let (t, k, c) = curve_shape(-d1, d2, -d3);
|
||||||
|
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01);
|
||||||
|
assert!(k.dist(bend) / bend.len() < 1e-3);
|
||||||
|
assert!((c.y - change.y).abs() < 1e-3, "and the far end holds it too: {c:?} vs {change:?}");
|
||||||
|
assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9);
|
||||||
|
}
|
||||||
|
// With G3, moving the frames' own value does move the face there.
|
||||||
|
let g3_moved = loft_blend(&moved, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap();
|
||||||
|
let (_, d1, d2, d3) = run_derivs(&g3_moved, 0.0, 0.5, false, 1e-3);
|
||||||
|
assert!((curve_shape(d1, d2, d3).2.y - free.y).abs() > 0.5, "a G3 blend follows the frame's change");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_sections_join_at_c3() {
|
||||||
|
let n = 9;
|
||||||
|
let sections: Vec<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(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap();
|
||||||
|
assert_runs_through(&body, §ions, n);
|
||||||
|
|
||||||
|
// Every joint now has a fourth station for the third difference, so the two sides agree in
|
||||||
|
// the tangent, the curvature and its change: the joints are C³.
|
||||||
|
let h = 1.0 / 3.0;
|
||||||
|
for (i, s) in [(1usize, 0usize), (1, 4), (1, 8), (2, 0), (2, 4), (2, 8)] {
|
||||||
|
let t = i as f64 / 3.0;
|
||||||
|
let v = s as f64 / (n - 1) as f64;
|
||||||
|
let (_, left1, left2, left3) = run_derivs(&body, t, v, true, 1e-4);
|
||||||
|
let (_, right1, right2, right3) = run_derivs(&body, t, v, false, 1e-4);
|
||||||
|
assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "joint {i} station {s}: the tangent agrees");
|
||||||
|
assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "joint {i} station {s}: the curvature agrees");
|
||||||
|
// The change of curvature agrees too, as far as finite differences can tell: its
|
||||||
|
// mismatch falls away with the step size, so the joint is C³, not just C².
|
||||||
|
assert!(
|
||||||
|
(left3 - right3).len() < 2e-2 * (1.0 + left3.len()),
|
||||||
|
"joint {i} station {s}: the change of curvature agrees: {left3:?} vs {right3:?}"
|
||||||
|
);
|
||||||
|
// What they hold are the sections' own differences along the run.
|
||||||
|
let p = |k: usize| sections[k][s].p;
|
||||||
|
let d1 = (p(i + 1) - p(i - 1)) * (1.0 / (2.0 * h));
|
||||||
|
let d2 = (p(i + 1) - p(i) * 2.0 + p(i - 1)) * (1.0 / (h * h));
|
||||||
|
let d3 = if i == 1 {
|
||||||
|
(p(0) * -1.0 + p(1) * 3.0 - p(2) * 3.0 + p(3)) * (1.0 / (h * h * h))
|
||||||
|
} else {
|
||||||
|
(p(0) - p(1) * 3.0 + p(2) * 3.0 - p(3)) * (1.0 / (h * h * h))
|
||||||
|
};
|
||||||
|
assert!((left1 - d1).len() < 1e-7 * (1.0 + d1.len()), "the tangent is the secant: {left1:?} vs {d1:?}");
|
||||||
|
assert!((left2 - d2).len() < 1e-4 * (1.0 + d2.len()), "the curvature is the second difference: {left2:?} vs {d2:?}");
|
||||||
|
assert!((left3 - d3).len() < 2e-2 * (1.0 + d3.len()), "the change of curvature is the third difference: {left3:?} vs {d3:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shape_that_is_not_a_scaled_copy_still_runs_through() {
|
||||||
|
let n = 17;
|
||||||
|
let sections = vec![
|
||||||
|
circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO),
|
||||||
|
rounded_rect_section(9.0, 7.0, 2.5, 10.0, n),
|
||||||
|
circle_section(8.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO),
|
||||||
|
];
|
||||||
|
let body = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap();
|
||||||
|
assert_runs_through(&body, §ions, n);
|
||||||
|
|
||||||
|
// The interior joint is still smooth to C² and holds the neighbours' own estimates, whatever
|
||||||
|
// the sections' shapes are.
|
||||||
|
for s in [0usize, 4, 8, 12, 16] {
|
||||||
|
let v = s as f64 / (n - 1) as f64;
|
||||||
|
let (_, left1, left2, _) = run_derivs(&body, 0.5, v, false, 2e-4);
|
||||||
|
let (_, right1, right2, _) = run_derivs(&body, 0.5, v, true, 2e-4);
|
||||||
|
assert!((left1 - right1).len() < 1e-6 * (1.0 + left1.len()), "station {s}: the tangent is continuous");
|
||||||
|
assert!((left2 - right2).len() < 1e-6 * (1.0 + left2.len()), "station {s}: the curvature is continuous");
|
||||||
|
let p = |k: usize| sections[k][s].p;
|
||||||
|
let secant = p(2) - p(0);
|
||||||
|
let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0;
|
||||||
|
assert!((left1 - secant).len() < 1e-6 * (1.0 + secant.len()), "station {s}: the tangent is the secant: {left1:?} vs {secant:?}");
|
||||||
|
assert!((left2 - second).len() < 1e-6 * (1.0 + second.len()), "station {s}: the curvature is the second difference: {left2:?} vs {second:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_two_section_blend_is_the_bridge() {
|
||||||
|
let (a, b) = valley_between_cylinders(9, 10.0);
|
||||||
|
let made = loft_blend(&[a.clone(), b.clone()], [Continuity::G2, Continuity::G2], [0.0, 0.0], 9).unwrap();
|
||||||
|
let other = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
|
||||||
|
// The blend runs with the sections, the bridge crosses from one boundary to the other: the
|
||||||
|
// same face, its two parameters the other way round.
|
||||||
|
for (run, station) in [(0.0, 0.0), (0.25, 0.3), (0.5, 0.7), (0.75, 0.1), (1.0, 1.0)] {
|
||||||
|
let x = from_p3(surface_of(&made).subs(run, station));
|
||||||
|
let y = from_p3(surface_of(&other).subs(station, run));
|
||||||
|
assert!(x.dist(y) < 1e-9, "run {run} station {station}: {x:?} vs {y:?}");
|
||||||
|
}
|
||||||
|
let grid = sample_grid(&other, 9, 7).unwrap();
|
||||||
|
for (row, frame) in grid.iter().zip(&a) {
|
||||||
|
assert!(row[0].dist(frame.p) < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crown_shapes_the_middle_without_moving_the_joints() {
|
||||||
|
let n = 9;
|
||||||
|
let sections = three_circles(n);
|
||||||
|
let mut heights = Vec::new();
|
||||||
|
let mut joints = Vec::new();
|
||||||
|
for crown in [-0.5, 0.0, 1.0, 2.0] {
|
||||||
|
let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [crown, crown], n).unwrap();
|
||||||
|
// The interior joint holds the sections' own estimates whatever the crown does: those do
|
||||||
|
// not depend on it.
|
||||||
|
let (p, d1, d2, _) = run_derivs(&body, 0.5, 0.5, false, 2e-4);
|
||||||
|
assert!(p.dist(sections[1][4].p) < 1e-9, "the crown left the joint where the sections put it");
|
||||||
|
joints.push((d1, d2));
|
||||||
|
// Halfway up the first span is where a fuller crown reaches further.
|
||||||
|
heights.push(from_p3(surface_of(&body).subs(0.25, 0.5)).z);
|
||||||
|
assert_runs_through(&body, §ions, n);
|
||||||
|
}
|
||||||
|
for (d1, d2) in &joints {
|
||||||
|
let (base1, base2) = joints[0];
|
||||||
|
assert!((*d1 - base1).len() < 1e-4 * (1.0 + base1.len()), "the crown left the joint's tangent alone: {d1:?}");
|
||||||
|
assert!((*d2 - base2).len() < 1e-4 * (1.0 + base2.len()), "the crown left the joint's curvature alone: {d2:?}");
|
||||||
|
}
|
||||||
|
let steps: Vec<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);
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 } => {
|
||||||
|
|||||||
@@ -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 |
Reference in new issue
Block a user