mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Continuity Analysis measures G3 and G4; the dialogs offer what each construction builds
Each seam now also reports the jump of the curvature's arc-length derivative across it (G3) and of its second derivative (G4), both from the faces' exact partials, and its order: the highest one every station meets within the tolerances (two new tolerances, defaults in the kernel). The verdict keeps its meaning (smooth is G2 or better). inspect.continuity replies with the new metrics, each seam's order and a count per order; the viewport widens G3 and G4 seams, the legend and the marker labels name every order in words. The Patch, Blend and Boundary rows offer the orders the kernel's Construction table says they build. G4's words in en, ar, de, he, ja, ko and zh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,8 @@ const SEAM_ANGLE_DEG: f64 = 1.0;
|
||||
/// with how many seams and stations clear there were. An error where the seams can't be measured.
|
||||
fn curvature_jumps(body: &solidcraft_engine::kernel::Body, near: [f64; 3], within: f64, clear: f64) -> Result<Value, String> {
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
let tol = solidcraft_engine::kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: SEAM_ANGLE_DEG, curvature_per_mm: 0.0 };
|
||||
let tol =
|
||||
solidcraft_engine::kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: SEAM_ANGLE_DEG, curvature_per_mm: 0.0, ..Default::default() };
|
||||
let report = solidcraft_engine::kernel::seam_diagnostics(body, &[], SEAM_SAMPLES, tol).map_err(|e| e.to_string())?;
|
||||
let at = Vec3::new(near[0], near[1], near[2]);
|
||||
let seams: Vec<_> = report
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.continuity_smooth_curvature_change": "Smooth curvature change",
|
||||
"ui.continuity_smooth_curvature_change_tip": "Smooth curvature change (G4): even the way the curvature changes runs on smoothly across the seam."
|
||||
},
|
||||
"ar": {
|
||||
"ui.continuity_smooth_curvature_change": "تغيّر انحناء سلس",
|
||||
"ui.continuity_smooth_curvature_change_tip": "تغيّر انحناء سلس (G4): حتى طريقة تغيّر الانحناء تستمر بسلاسة عبر خط الالتقاء."
|
||||
},
|
||||
"de": {
|
||||
"ui.continuity_smooth_curvature_change": "Glatte Krümmungsänderung",
|
||||
"ui.continuity_smooth_curvature_change_tip": "Glatte Krümmungsänderung (G4): Selbst die Art, wie sich die Krümmung ändert, läuft an der Naht glatt weiter."
|
||||
},
|
||||
"he": {
|
||||
"ui.continuity_smooth_curvature_change": "שינוי עקמומיות חלק",
|
||||
"ui.continuity_smooth_curvature_change_tip": "שינוי עקמומיות חלק (G4): גם האופן שבו העקמומיות משתנה ממשיך בצורה חלקה לאורך התפר."
|
||||
},
|
||||
"ja": {
|
||||
"ui.continuity_smooth_curvature_change": "なめらかな曲率変化",
|
||||
"ui.continuity_smooth_curvature_change_tip": "なめらかな曲率変化 (G4): 境目で曲率の変わり方そのものもなめらかに続きます。"
|
||||
},
|
||||
"ko": {
|
||||
"ui.continuity_smooth_curvature_change": "부드러운 곡률 변화",
|
||||
"ui.continuity_smooth_curvature_change_tip": "부드러운 곡률 변화(G4): 이음매에서 곡률이 변하는 방식까지 부드럽게 이어집니다."
|
||||
},
|
||||
"zh": {
|
||||
"ui.continuity_smooth_curvature_change": "平滑曲率变化",
|
||||
"ui.continuity_smooth_curvature_change_tip": "平滑曲率变化 (G4):连曲率的变化方式在接缝处也平滑延续。"
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,6 @@ 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;
|
||||
@@ -48,11 +43,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
|
||||
"body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
|
||||
a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
|
||||
gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \
|
||||
gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01), \
|
||||
curvature_change_tolerance_per_mm2? (default 0.001), curvature_change_rate_tolerance_per_mm3? (default 0.0001); \
|
||||
add?: true keeps the seams already shaded and adds these (measuring an edge again replaces it); \
|
||||
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",
|
||||
clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1), curvature jump (G2), \
|
||||
jump of the curvature's change across the seam dκ/ds (G3) and of that change's rate d²κ/ds² (G4), from the \
|
||||
faces' exact derivatives, as {value, at: [x,y,z], edge_fraction}; its order (g0…g4: the highest every station \
|
||||
meets within the tolerances, null where the faces do not meet), its verdict (smooth: g2 or better, \
|
||||
curvature_jump, sharp or gap) and a summary; the seams are shaded in the viewport",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -127,10 +125,13 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")),
|
||||
},
|
||||
};
|
||||
let default = kernel::ContinuityTolerances::default();
|
||||
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)?,
|
||||
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)?,
|
||||
curvature_change_per_mm2: tolerance(p, "curvature_change_tolerance_per_mm2", default.curvature_change_per_mm2)?,
|
||||
curvature_change_rate_per_mm3: tolerance(p, "curvature_change_rate_tolerance_per_mm3", default.curvature_change_rate_per_mm3)?,
|
||||
};
|
||||
let picks = edge_picks(p)?;
|
||||
let st = s.world_state()?;
|
||||
@@ -177,8 +178,14 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 { body: body.name.clone(), edge: seam.edge, points: seam.points.clone(), verdict: seam.verdict });
|
||||
summary.count(seam.verdict, seam.order);
|
||||
seams.push(ContinuitySeam {
|
||||
body: body.name.clone(),
|
||||
edge: seam.edge,
|
||||
points: seam.points.clone(),
|
||||
verdict: seam.verdict,
|
||||
order: seam.order,
|
||||
});
|
||||
rows.push(json!({
|
||||
"edge": seam.edge,
|
||||
"length_mm": seam.length,
|
||||
@@ -186,6 +193,9 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"gap_mm": seam.gap_mm,
|
||||
"normal_angle_deg": seam.normal_angle_deg,
|
||||
"curvature_jump_per_mm": seam.curvature_jump_per_mm,
|
||||
"curvature_change_jump_per_mm2": seam.curvature_change_jump_per_mm2,
|
||||
"curvature_change_rate_jump_per_mm3": seam.curvature_change_rate_jump_per_mm3,
|
||||
"order": seam.order,
|
||||
"verdict": seam.verdict,
|
||||
}));
|
||||
}
|
||||
@@ -213,6 +223,8 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"gap_mm": tolerances.gap_mm,
|
||||
"angle_deg": tolerances.angle_deg,
|
||||
"curvature_per_mm": tolerances.curvature_per_mm,
|
||||
"curvature_change_per_mm2": tolerances.curvature_change_per_mm2,
|
||||
"curvature_change_rate_per_mm3": tolerances.curvature_change_rate_per_mm3,
|
||||
},
|
||||
"bodies": bodies,
|
||||
"summary": {
|
||||
@@ -223,6 +235,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"gap": summary.gap,
|
||||
"skipped": summary.skipped,
|
||||
"unmeasured": summary.unmeasured,
|
||||
"orders": summary.orders(),
|
||||
"worst": summary.worst(),
|
||||
},
|
||||
}))
|
||||
@@ -274,11 +287,16 @@ struct Summary {
|
||||
gap: usize,
|
||||
skipped: usize,
|
||||
unmeasured: usize,
|
||||
/// Seams by the highest order they meet, indexed by [`kernel::Continuity::order`].
|
||||
by_order: [usize; 5],
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn count(&mut self, verdict: kernel::SeamVerdict) {
|
||||
fn count(&mut self, verdict: kernel::SeamVerdict, order: Option<kernel::Continuity>) {
|
||||
self.edges += 1;
|
||||
if let Some(slot) = order.and_then(|g| self.by_order.get_mut(g.order())) {
|
||||
*slot += 1;
|
||||
}
|
||||
match verdict {
|
||||
kernel::SeamVerdict::Smooth => self.smooth += 1,
|
||||
kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1,
|
||||
@@ -287,6 +305,11 @@ impl Summary {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many seams meet each order at best, by its word (`g0` … `g4`).
|
||||
fn orders(&self) -> serde_json::Map<String, Value> {
|
||||
kernel::Continuity::ALL.into_iter().zip(self.by_order).map(|(g, n)| (g.as_str().to_string(), json!(n))).collect()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -362,6 +385,7 @@ mod tests {
|
||||
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["order"], json!("g0"), "{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}");
|
||||
@@ -370,7 +394,11 @@ mod tests {
|
||||
}
|
||||
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["tolerances"],
|
||||
json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01, "curvature_change_per_mm2": 1e-3, "curvature_change_rate_per_mm3": 1e-4})
|
||||
);
|
||||
assert_eq!(v["summary"]["orders"], json!({"g0": 12, "g1": 0, "g2": 0, "g3": 0, "g4": 0}));
|
||||
assert_eq!(v["summary"]["edges"], json!(12));
|
||||
assert_eq!(v["summary"]["sharp"], json!(12));
|
||||
assert_eq!(v["summary"]["worst"], json!("sharp"));
|
||||
@@ -395,7 +423,12 @@ mod tests {
|
||||
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!(e["order"], json!("g1"), "a plane tangent to a cylinder is G1, not G2: {e}");
|
||||
assert!(e["curvature_change_jump_per_mm2"]["value"].as_f64().unwrap() < 1e-9, "{e}");
|
||||
assert!(e["curvature_change_rate_jump_per_mm3"]["value"].as_f64().unwrap() < 1e-9, "{e}");
|
||||
}
|
||||
assert_eq!(v["summary"]["orders"]["g1"], json!(2));
|
||||
assert_eq!(s.continuity.iter().filter(|seam| seam.order == Some(crate::kernel::Continuity::G1)).count(), 2);
|
||||
assert_eq!(v["summary"]["curvature_jump"], json!(2));
|
||||
assert_eq!(v["summary"]["sharp"], json!(13));
|
||||
assert_eq!(v["summary"]["smooth"], json!(0));
|
||||
@@ -473,6 +506,8 @@ mod tests {
|
||||
json!({"gap_tolerance_mm": 1e12}),
|
||||
json!({"angle_tolerance_deg": f64::NAN}),
|
||||
json!({"curvature_tolerance_per_mm": "tight"}),
|
||||
json!({"curvature_change_tolerance_per_mm2": -0.5}),
|
||||
json!({"curvature_change_rate_tolerance_per_mm3": null}),
|
||||
json!({"edges": 3}),
|
||||
json!({"edges": [[1, 2]]}),
|
||||
json!({"edges": [[]]}),
|
||||
|
||||
@@ -503,6 +503,9 @@ pub struct ContinuitySeam {
|
||||
pub points: Vec<Vec3>,
|
||||
/// The seam's worst verdict along its length.
|
||||
pub verdict: kernel::SeamVerdict,
|
||||
/// The highest continuity order it meets along its whole length (`None`: the faces do not
|
||||
/// meet).
|
||||
pub order: Option<kernel::Continuity>,
|
||||
}
|
||||
|
||||
/// A face Isocurve Analysis shows: the body, the picked point on the face (its face is found
|
||||
|
||||
@@ -1040,7 +1040,7 @@ fn a_modelled_threads_flank_joins_are_not_drawn() {
|
||||
.get(f as usize)
|
||||
.is_some_and(|f| f.nurbs.is_none() && matches!(f.surface, Surface::Spline { .. } | Surface::RationalSpline { .. }))
|
||||
};
|
||||
let tolerances = solidcraft_kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: 0.5, curvature_per_mm: 1.0 };
|
||||
let tolerances = solidcraft_kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: 0.5, curvature_per_mm: 1.0, ..Default::default() };
|
||||
let report = solidcraft_kernel::seam_diagnostics(body, &[], 33, tolerances).unwrap();
|
||||
let mut flank_joins = 0;
|
||||
let mut worst: f64 = 0.0;
|
||||
|
||||
+221
-85
@@ -1,12 +1,18 @@
|
||||
//! 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)?
|
||||
//! at an edge share a curve (G0), a tangent plane (G1), a curvature (G2), the way that curvature
|
||||
//! changes across the seam (G3) and the way that change changes (G4)?
|
||||
//!
|
||||
//! 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
|
||||
//! the edge, from the second fundamental form of each surface (G2); the curvature-change jump and
|
||||
//! its rate's jump compare the first and second arc-length derivatives of those normal sections'
|
||||
//! curvatures across the edge (G3, G4). All three come from each surface's exact partial
|
||||
//! derivatives ([`solidcraft_geom::brep::normal_section`]), never from samples. Each seam's
|
||||
//! [`SeamReport::order`] is the highest order whose test, and every lower one, its worst station
|
||||
//! passes. 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.
|
||||
//!
|
||||
@@ -15,9 +21,11 @@
|
||||
|
||||
use serde::Serialize;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::brep::normal_section::{SectionJet, normal_section};
|
||||
use solidcraft_geom::brep::{self as b, eval::CurveEval, eval::SurfaceEval};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::continuity_order::Continuity;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Bodies with more edges than this are refused rather than sampled for minutes.
|
||||
@@ -54,8 +62,37 @@ pub struct ContinuityTolerances {
|
||||
pub angle_deg: f64,
|
||||
/// Largest curvature jump that still counts as curvature continuous (G2), 1/mm.
|
||||
pub curvature_per_mm: f64,
|
||||
/// Largest jump of the curvature's change across the seam that still counts as G3, 1/mm².
|
||||
pub curvature_change_per_mm2: f64,
|
||||
/// Largest jump of that change's rate that still counts as G4, 1/mm³.
|
||||
pub curvature_change_rate_per_mm3: f64,
|
||||
}
|
||||
|
||||
impl Default for ContinuityTolerances {
|
||||
/// A micron of gap, a degree of normal angle, a curvature jump of 1/100 mm, and a change of
|
||||
/// curvature (and its rate) a tenth as large again per millimetre.
|
||||
fn default() -> Self {
|
||||
ContinuityTolerances {
|
||||
gap_mm: 1e-3,
|
||||
angle_deg: 1.0,
|
||||
curvature_per_mm: 1e-2,
|
||||
curvature_change_per_mm2: 1e-3,
|
||||
curvature_change_rate_per_mm3: 1e-4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContinuityTolerances {
|
||||
/// The tolerance of each metric, in [`Metrics`] order (G0 … G4).
|
||||
fn by_order(&self) -> Metrics {
|
||||
[self.gap_mm, self.angle_deg, self.curvature_per_mm, self.curvature_change_per_mm2, self.curvature_change_rate_per_mm3]
|
||||
}
|
||||
}
|
||||
|
||||
/// One value per order a seam is tested for, G0 … G4: the gap, the normal angle, and the jumps of
|
||||
/// the curvature, of its change, and of that change's rate.
|
||||
type Metrics = [f64; 5];
|
||||
|
||||
/// The worst value of one metric along an edge, with where it was found.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct SeamWorst {
|
||||
@@ -67,8 +104,9 @@ pub struct SeamWorst {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// curvature continuous (G2 or better: [`SeamReport::order`] says how much 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 {
|
||||
@@ -78,6 +116,18 @@ pub enum SeamVerdict {
|
||||
Gap,
|
||||
}
|
||||
|
||||
impl SeamVerdict {
|
||||
/// The verdict of a seam whose highest order met is `order` (`None`: not even G0).
|
||||
pub fn of(order: Option<Continuity>) -> SeamVerdict {
|
||||
match order {
|
||||
None => SeamVerdict::Gap,
|
||||
Some(Continuity::G0) => SeamVerdict::Sharp,
|
||||
Some(Continuity::G1) => SeamVerdict::CurvatureJump,
|
||||
Some(_) => SeamVerdict::Smooth,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge's diagnostics: the worst of each metric along it, and where.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SeamReport {
|
||||
@@ -90,7 +140,14 @@ pub struct SeamReport {
|
||||
pub gap_mm: SeamWorst,
|
||||
pub normal_angle_deg: SeamWorst,
|
||||
pub curvature_jump_per_mm: SeamWorst,
|
||||
/// What the tolerances make of the seam.
|
||||
/// The worst jump of the curvature's arc-length derivative across the seam (G3), 1/mm².
|
||||
pub curvature_change_jump_per_mm2: SeamWorst,
|
||||
/// The worst jump of its second derivative (G4), 1/mm³.
|
||||
pub curvature_change_rate_jump_per_mm3: SeamWorst,
|
||||
/// The highest order every station meets within the tolerances (`None`: the faces do not
|
||||
/// meet).
|
||||
pub order: Option<Continuity>,
|
||||
/// What the tolerances make of the seam ([`SeamVerdict::of`] its order).
|
||||
pub verdict: SeamVerdict,
|
||||
/// The stations in order: what a viewport shades.
|
||||
pub points: Vec<Vec3>,
|
||||
@@ -98,7 +155,7 @@ pub struct SeamReport {
|
||||
pub stations: Vec<SeamStation>,
|
||||
}
|
||||
|
||||
/// One station of a seam: how far along the edge, and the three metrics there.
|
||||
/// One station of a seam: how far along the edge, and the five metrics there.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct SeamStation {
|
||||
/// 0 at the edge's start, 1 at its end.
|
||||
@@ -106,6 +163,28 @@ pub struct SeamStation {
|
||||
pub gap_mm: f64,
|
||||
pub normal_angle_deg: f64,
|
||||
pub curvature_jump_per_mm: f64,
|
||||
pub curvature_change_jump_per_mm2: f64,
|
||||
pub curvature_change_rate_jump_per_mm3: f64,
|
||||
}
|
||||
|
||||
impl SeamStation {
|
||||
fn new(edge_fraction: f64, m: Metrics) -> SeamStation {
|
||||
let [gap_mm, normal_angle_deg, curvature_jump_per_mm, curvature_change_jump_per_mm2, curvature_change_rate_jump_per_mm3] = m;
|
||||
SeamStation {
|
||||
edge_fraction,
|
||||
gap_mm,
|
||||
normal_angle_deg,
|
||||
curvature_jump_per_mm,
|
||||
curvature_change_jump_per_mm2,
|
||||
curvature_change_rate_jump_per_mm3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest order whose metric, and every lower one, is within its tolerance.
|
||||
fn highest_met(worst: Metrics, tolerances: Metrics) -> Option<Continuity> {
|
||||
let met = worst.iter().zip(tolerances).take_while(|(w, t)| **w <= *t).count();
|
||||
met.checked_sub(1).and_then(Continuity::from_order)
|
||||
}
|
||||
|
||||
/// The seam diagnostics of one body.
|
||||
@@ -160,7 +239,8 @@ pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances
|
||||
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)] {
|
||||
let names = ["gap", "angle", "curvature", "curvature change", "curvature change rate"];
|
||||
for (name, value) in names.into_iter().zip(tolerances.by_order()) {
|
||||
// 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")));
|
||||
@@ -289,7 +369,7 @@ fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityT
|
||||
Ok(ContinuityReport { seams, skipped, unmeasured })
|
||||
}
|
||||
|
||||
/// One edge's worst gap, normal angle and curvature jump.
|
||||
/// One edge's worst gap, normal angle and jumps of curvature, its change and that change's rate.
|
||||
fn seam(
|
||||
index: usize,
|
||||
curve: &CurveEval,
|
||||
@@ -301,52 +381,22 @@ fn seam(
|
||||
) -> Option<SeamReport> {
|
||||
// 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 gap = None;
|
||||
let mut angle = None;
|
||||
let mut jump = None;
|
||||
let mut worst: [Option<SeamWorst>; 5] = [None; 5];
|
||||
let mut points = Vec::with_capacity(samples);
|
||||
let mut stations = Vec::with_capacity(samples);
|
||||
for k in 0..samples {
|
||||
let f = fraction(k);
|
||||
let f = k as f64 / steps as f64;
|
||||
let Some((at, tangent)) = curve.derivs(t0 + (t1 - t0) * f) else { continue };
|
||||
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 Some(tangent) = tangent.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 };
|
||||
let station = SeamStation {
|
||||
edge_fraction: f,
|
||||
gap_mm: qa.dist(qb),
|
||||
normal_angle_deg: na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(),
|
||||
curvature_jump_per_mm: signed.abs(),
|
||||
};
|
||||
let Some(metrics) = station(at, tangent, &mut on_a, &mut on_b) else { continue };
|
||||
points.push(at);
|
||||
stations.push(station);
|
||||
keep(&mut gap, station.gap_mm, at, f);
|
||||
keep(&mut angle, station.normal_angle_deg, at, f);
|
||||
keep(&mut jump, station.curvature_jump_per_mm, at, f);
|
||||
stations.push(SeamStation::new(f, metrics));
|
||||
for (w, value) in worst.iter_mut().zip(metrics) {
|
||||
keep(w, value, 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 [gap, angle, jump, change, rate] = worst;
|
||||
let [gap, angle, jump, change, rate] = [gap?, angle?, jump?, change?, rate?];
|
||||
let order = highest_met([gap.value, angle.value, jump.value, change.value, rate.value], tolerances.by_order());
|
||||
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,
|
||||
@@ -355,12 +405,38 @@ fn seam(
|
||||
gap_mm: gap,
|
||||
normal_angle_deg: angle,
|
||||
curvature_jump_per_mm: jump,
|
||||
verdict,
|
||||
curvature_change_jump_per_mm2: change,
|
||||
curvature_change_rate_jump_per_mm3: rate,
|
||||
order,
|
||||
verdict: SeamVerdict::of(order),
|
||||
points,
|
||||
stations,
|
||||
})
|
||||
}
|
||||
|
||||
/// The five metrics at the seam point `at` (edge tangent `tangent`): both projections and both
|
||||
/// faces' normal sections square to the seam, or the station says nothing.
|
||||
fn station(at: Vec3, tangent: Vec3, on_a: &mut FaceSurface, on_b: &mut FaceSurface) -> Option<Metrics> {
|
||||
if !at.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let ((qa, na, uv_a), (qb, nb, uv_b)) = (on_a.project(at)?, on_b.project(at)?);
|
||||
let tangent = tangent.normalized()?;
|
||||
let ja = on_a.section(uv_a, na, across(na, tangent))?;
|
||||
let jb = on_b.section(uv_b, nb, across(nb, tangent))?;
|
||||
// The faces' outward normals agree on a smooth seam; where they are opposed (a knife edge)
|
||||
// the sections are compared in one frame: face b's normal and its across direction both turn
|
||||
// round, so its k-th derivative of curvature reads (−1)^(k+1) times as much in face a's.
|
||||
let opposed = na.dot(nb) < 0.0;
|
||||
let jump = |k: usize, a: f64, b: f64| {
|
||||
let flip = if opposed && k % 2 == 0 { -1.0 } else { 1.0 };
|
||||
(a - flip * b).abs()
|
||||
};
|
||||
let [ka, ca, ra] = ja.values();
|
||||
let [kb, cb, rb] = jb.values();
|
||||
Some([qa.dist(qb), na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), jump(0, ka, kb), jump(1, ca, cb), jump(2, ra, rb)])
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -402,40 +478,12 @@ impl FaceSurface {
|
||||
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; 2], normal: Vec3, direction: Vec3) -> Option<f64> {
|
||||
let (_, ru, rv) = self.surface.derivs(uv[0], uv[1])?;
|
||||
let (ruu, ruv, rvv) = self.surface.second(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(ruu * (du * du) + ruv * (2.0 * du * dv) + rvv * (dv * dv));
|
||||
(second / (speed * speed)).is_finite().then_some(second / (speed * speed))
|
||||
/// The face's normal section at `uv` along `direction` (a unit vector of its tangent plane):
|
||||
/// its curvature `κ = n · r″` and κ's first two arc-length derivatives, from the surface's
|
||||
/// exact partials. 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).
|
||||
fn section(&self, uv: [f64; 2], normal: Vec3, direction: Vec3) -> Option<SectionJet> {
|
||||
normal_section(&self.surface.partials(uv[0], uv[1])?, normal, direction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,10 +491,10 @@ impl FaceSurface {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Plane;
|
||||
use solidcraft_geom::{Plane, Seg2};
|
||||
|
||||
fn tolerances() -> ContinuityTolerances {
|
||||
ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 }
|
||||
ContinuityTolerances::default()
|
||||
}
|
||||
|
||||
fn box_body() -> Body {
|
||||
@@ -468,6 +516,7 @@ mod tests {
|
||||
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.order, Some(Continuity::G0));
|
||||
assert_eq!(seam.samples, 33);
|
||||
assert!((seam.length - 10.0).abs() < 1e-9);
|
||||
}
|
||||
@@ -488,6 +537,9 @@ mod tests {
|
||||
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);
|
||||
// A plane tangent to a cylinder: G1, not G2. Neither section's curvature changes.
|
||||
assert_eq!(seam.order, Some(Continuity::G1));
|
||||
assert!(seam.curvature_change_jump_per_mm2.value < 1e-9 && seam.curvature_change_rate_jump_per_mm3.value < 1e-9);
|
||||
}
|
||||
assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13);
|
||||
}
|
||||
@@ -517,6 +569,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3 mm tall prism over a profile that runs along a line from (-20, 0) to the origin, on
|
||||
/// into `transition` (starting at the origin, leaving along +x), and back straight to
|
||||
/// (-20, 20): its one tangent wall seam is the line meeting the transition.
|
||||
fn line_into(transition: Seg2) -> Body {
|
||||
use solidcraft_geom::{Loop2, Region2, Vec2};
|
||||
let end = match transition {
|
||||
Seg2::Cubic { p3, .. } => p3,
|
||||
Seg2::Bezier { n, p } => p[usize::from(n)],
|
||||
_ => Vec2::new(0.0, 0.0),
|
||||
};
|
||||
let outer = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(-20.0, 0.0), b: Vec2::new(0.0, 0.0) },
|
||||
transition,
|
||||
Seg2::Line { a: end, b: Vec2::new(-20.0, 20.0) },
|
||||
Seg2::Line { a: Vec2::new(-20.0, 20.0), b: Vec2::new(-20.0, 0.0) },
|
||||
],
|
||||
};
|
||||
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0)
|
||||
}
|
||||
|
||||
/// The one seam of `body` where the faces are tangent.
|
||||
fn tangent_seam(body: &Body) -> SeamReport {
|
||||
let r = report(body);
|
||||
let mut tangent: Vec<SeamReport> = r.seams.into_iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
|
||||
assert_eq!(tangent.len(), 1, "{tangent:?}");
|
||||
tangent.remove(0)
|
||||
}
|
||||
|
||||
/// A plane into a cubic whose first three control points lie on the plane's line: the cubic
|
||||
/// starts with zero curvature (G2), but its curvature changes there. For control points
|
||||
/// (0,0), (L,0), (2L,0), (3L,H): B′ = (3L, 0), B″ = 0 and B‴ = (0, 6H) at the start, so
|
||||
/// dκ/dt = (x′y‴ − y′x‴) / |B′|³ = 2H / (3L²) and, at ds/dt = 3L, dκ/ds = 2H / (9L³): with
|
||||
/// L = H = 10, 2 / 900 per mm².
|
||||
#[test]
|
||||
fn a_plane_into_a_g2_transition_is_curvature_continuous_but_not_g3() {
|
||||
use solidcraft_geom::Vec2;
|
||||
let v = Vec2::new;
|
||||
let body = line_into(Seg2::Cubic { p0: v(0.0, 0.0), p1: v(10.0, 0.0), p2: v(20.0, 0.0), p3: v(30.0, 10.0) });
|
||||
let seam = tangent_seam(&body);
|
||||
assert!(seam.curvature_jump_per_mm.value < 1e-9, "{}", seam.curvature_jump_per_mm.value);
|
||||
let change = seam.curvature_change_jump_per_mm2.value;
|
||||
assert!((change - 2.0 / 900.0).abs() < 1e-9, "dκ/ds 2/900, got {change}");
|
||||
assert_eq!(seam.order, Some(Continuity::G2));
|
||||
assert_eq!(seam.verdict, SeamVerdict::Smooth, "G2 is still smooth to the older verdict");
|
||||
}
|
||||
|
||||
/// A plane into a quartic whose first four control points are evenly spaced on the plane's
|
||||
/// line: curvature and its change both start at zero (G3), but the change's rate does not.
|
||||
/// For (0,0), (L,0), (2L,0), (3L,0), (4L,H): B′ = (4L, 0), B″ = B‴ = 0 and B⁗ = (0, 24H),
|
||||
/// so d²κ/dt² = (x′y⁗ − y′x⁗) / |B′|³ = 3H / (2L²) and, at ds/dt = 4L, d²κ/ds² = 3H / (32L⁴):
|
||||
/// with L = 5, H = 10, 3 / 2000 per mm³.
|
||||
#[test]
|
||||
fn a_plane_into_a_g3_transition_holds_the_change_of_curvature_but_not_g4() {
|
||||
use solidcraft_geom::Vec2;
|
||||
let mut p = [Vec2::new(0.0, 0.0); solidcraft_geom::MAX_BEZIER_CTRL];
|
||||
for (k, q) in [(5.0, 0.0), (10.0, 0.0), (15.0, 0.0), (20.0, 10.0)].into_iter().enumerate() {
|
||||
p[k + 1] = Vec2::new(q.0, q.1);
|
||||
}
|
||||
let body = line_into(Seg2::Bezier { n: 4, p });
|
||||
let seam = tangent_seam(&body);
|
||||
assert!(seam.curvature_jump_per_mm.value < 1e-9 && seam.curvature_change_jump_per_mm2.value < 1e-9, "{seam:?}");
|
||||
let rate = seam.curvature_change_rate_jump_per_mm3.value;
|
||||
assert!((rate - 3.0 / 2000.0).abs() < 1e-9, "d²κ/ds² 3/2000, got {rate}");
|
||||
assert_eq!(seam.order, Some(Continuity::G3));
|
||||
// Within a looser rate tolerance the same seam reads G4.
|
||||
let loose = ContinuityTolerances { curvature_change_rate_per_mm3: 1e-2, ..tolerances() };
|
||||
let r = seam_diagnostics(&body, &[seam.edge], 33, loose).unwrap();
|
||||
assert_eq!(r.seams[0].order, Some(Continuity::G4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_order_met_needs_every_order_below_it() {
|
||||
let tol = [1.0; 5];
|
||||
assert_eq!(highest_met([0.0; 5], tol), Some(Continuity::G4));
|
||||
assert_eq!(highest_met([0.0, 0.0, 2.0, 0.0, 0.0], tol), Some(Continuity::G1), "a G2 failure caps the order");
|
||||
assert_eq!(highest_met([2.0, 0.0, 0.0, 0.0, 0.0], tol), None, "faces that do not meet have no order");
|
||||
assert_eq!(highest_met([0.0, 0.0, 0.0, f64::NAN, 0.0], tol), Some(Continuity::G2), "NaN never passes");
|
||||
for (order, verdict) in [(None, SeamVerdict::Gap), (Some(Continuity::G0), SeamVerdict::Sharp), (Some(Continuity::G3), SeamVerdict::Smooth)] {
|
||||
assert_eq!(SeamVerdict::of(order), verdict);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_refuse_what_is_not_there() {
|
||||
let body = box_body();
|
||||
@@ -531,6 +666,7 @@ mod tests {
|
||||
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, &[], 33, ContinuityTolerances { curvature_change_rate_per_mm3: -1.0, ..tolerances() }).is_err());
|
||||
assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ fn corner_seams(body: &Body, share: f64) -> (f64, f64, usize) {
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let edges: Vec<usize> = (0..rec.edges.len()).filter(|e| rec.edges[*e].uses.iter().any(|u| corner_faces.contains(&(u.face as usize)))).collect();
|
||||
let report = seam_diagnostics(body, &edges, 65, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap();
|
||||
let report =
|
||||
seam_diagnostics(body, &edges, 65, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6, ..Default::default() })
|
||||
.unwrap();
|
||||
// The corner's vertices where its boundary turns: an end of two of its seams whose
|
||||
// directions there differ.
|
||||
let ends: Vec<(Vec3, Vec3, f64)> = report
|
||||
|
||||
@@ -9,6 +9,7 @@ use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::doc::{AxisRef, Direction, FaceAt, FeatureKind, Mat, PlaneRef, Position, ProfileSel, RuledCorners, RuledMode};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::kernel::{Construction, Continuity};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, form_row, op_index};
|
||||
@@ -58,21 +59,21 @@ crate::dialog_descriptor::dialog_descriptors! {
|
||||
Loft {
|
||||
rail_fit: usize,
|
||||
},
|
||||
/// `continuity`: an index into [`BLEND_CONTINUITY`] for each end, [from, to]; `bulge`: how
|
||||
/// `continuity`: an index into [`BLEND_ORDERS`] for each end, [from, to]; `bulge`: how
|
||||
/// full each end leaves (0 is the plain bridge); `stitch`: sew the blend onto both bodies.
|
||||
Blend {
|
||||
continuity: [usize; 2],
|
||||
bulge: [f64; 2],
|
||||
stitch: bool,
|
||||
},
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
|
||||
/// `continuity`: an index into [`BOUNDARY_ORDERS`] (for a loop of sketch curves, how much of
|
||||
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
|
||||
/// beside them); `join`: sew the surface into the body of its edges.
|
||||
Boundary {
|
||||
continuity: usize,
|
||||
join: bool,
|
||||
},
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
|
||||
/// `continuity`: an index into [`PATCH_ORDERS`] (how a patch on edges meets the faces
|
||||
/// beside its loop).
|
||||
Patch {
|
||||
continuity: usize,
|
||||
@@ -137,29 +138,51 @@ impl Surf {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
|
||||
/// Labels are plain words; the order's code (G0, G1, G2) is in each one's hover text.
|
||||
const PATCH_CONTINUITY: [(&str, &str); 3] = [("ui.continuity_position", "g0"), ("ui.continuity_tangent", "g1"), ("ui.continuity_curvature", "g2")];
|
||||
/// A dialog's continuity choices: the orders a construction builds, from the kernel's table
|
||||
/// ([`Construction::offered`]), so an order a construction learns shows up here by itself. The
|
||||
/// dialog keeps an index into them; labels are plain words, each order's code (G0 …) is in the
|
||||
/// hover text.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Orders(Construction);
|
||||
|
||||
/// How much of the surface at its edges each end of a blend repeats (labels, and the
|
||||
/// `continuity` words).
|
||||
const BLEND_CONTINUITY: [(&str, &str); 4] =
|
||||
[("ui.continuity_position", "g0"), ("ui.continuity_tangent", "g1"), ("ui.continuity_curvature", "g2"), ("ui.continuity_curvature_change", "g3")];
|
||||
/// How a patch on edges meets the faces beside its loop.
|
||||
const PATCH_ORDERS: Orders = Orders(Construction::Patch);
|
||||
/// How much of the surface at its edges each end of a blend repeats.
|
||||
const BLEND_ORDERS: Orders = Orders(Construction::Bridge);
|
||||
/// A boundary's one row serves its loop patch (a bridge) and its curve network alike: the orders
|
||||
/// a network builds, the fewer of the two.
|
||||
const BOUNDARY_ORDERS: Orders = Orders(Construction::Network);
|
||||
|
||||
/// Each continuity's hover text, in the order of [`BLEND_CONTINUITY`]: where its code (G0 … G3)
|
||||
/// is shown.
|
||||
const CONTINUITY_TIPS: [&str; 4] =
|
||||
["ui.continuity_position_tip", "ui.continuity_tangent_tip", "ui.continuity_curvature_tip", "ui.continuity_curvature_change_tip"];
|
||||
impl Orders {
|
||||
fn all(self) -> Vec<Continuity> {
|
||||
self.0.offered()
|
||||
}
|
||||
|
||||
/// A Continuity row's segments over `orders` (the segments draw their text as given).
|
||||
fn continuity_segments(ui: &mut egui::Ui, selected: &mut usize, orders: &[(&str, &str)]) -> egui::Response {
|
||||
let labels: Vec<&str> = orders.iter().map(|(label, _)| crate::i18n::tr(label)).collect();
|
||||
ui.scope(|ui| ui_kit::segmented(ui, selected, &labels, Default::default())).response
|
||||
}
|
||||
/// The message ids of the labels, in order.
|
||||
fn labels(self) -> Vec<&'static str> {
|
||||
self.all().into_iter().map(Continuity::label_id).collect()
|
||||
}
|
||||
|
||||
/// The hover text of a list of `orders` continuities: a line for each.
|
||||
fn continuity_tip(orders: usize) -> String {
|
||||
CONTINUITY_TIPS.iter().take(orders).map(|tip| crate::i18n::tr(tip)).collect::<Vec<_>>().join("\n")
|
||||
/// The hover text: a line for each order.
|
||||
fn tip(self) -> String {
|
||||
self.all().into_iter().map(|g| crate::i18n::tr(g.tip_id())).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The command word of the choice at `index`.
|
||||
fn word(self, index: usize) -> Option<&'static str> {
|
||||
self.all().get(index).map(|g| g.as_str())
|
||||
}
|
||||
|
||||
/// The index of `g` among the choices (`None`: not offered).
|
||||
fn index(self, g: Continuity) -> Option<usize> {
|
||||
self.all().iter().position(|o| *o == g)
|
||||
}
|
||||
|
||||
/// A Continuity row's segments (the segments draw their text as given).
|
||||
fn segments(self, ui: &mut egui::Ui, selected: &mut usize) -> egui::Response {
|
||||
let labels: Vec<&str> = self.labels().into_iter().map(crate::i18n::tr).collect();
|
||||
ui.scope(|ui| ui_kit::segmented(ui, selected, &labels, Default::default())).response
|
||||
}
|
||||
}
|
||||
|
||||
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
|
||||
@@ -292,27 +315,23 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
ui_kit::options(ui, "untrim_options", &mut [("Restore outer boundary", outer), ("In place (surfaces)", in_place)]);
|
||||
}
|
||||
Surf::Patch { continuity } => {
|
||||
form_row(ui, "Continuity", |ui| continuity_segments(ui, continuity, &PATCH_CONTINUITY))
|
||||
.on_hover_text(continuity_tip(PATCH_CONTINUITY.len()));
|
||||
form_row(ui, "Continuity", |ui| PATCH_ORDERS.segments(ui, continuity)).on_hover_text(PATCH_ORDERS.tip());
|
||||
}
|
||||
Surf::Blend { continuity, bulge, stitch } => {
|
||||
let [from, to] = continuity;
|
||||
let [from_bulge, to_bulge] = bulge;
|
||||
form_row(ui, "Start continuity", |ui| ui_kit::dropdown(ui, "bl_cont0", from, &BLEND_CONTINUITY.map(|(label, _)| label)))
|
||||
.on_hover_text(continuity_tip(BLEND_CONTINUITY.len()));
|
||||
form_row(ui, "Start continuity", |ui| ui_kit::dropdown(ui, "bl_cont0", from, &BLEND_ORDERS.labels())).on_hover_text(BLEND_ORDERS.tip());
|
||||
form_row(ui, "Start bulge", |ui| {
|
||||
ui_kit::scalar_field(ui, from_bulge, -0.9..=3.0);
|
||||
});
|
||||
form_row(ui, "End continuity", |ui| ui_kit::dropdown(ui, "bl_cont1", to, &BLEND_CONTINUITY.map(|(label, _)| label)))
|
||||
.on_hover_text(continuity_tip(BLEND_CONTINUITY.len()));
|
||||
form_row(ui, "End continuity", |ui| ui_kit::dropdown(ui, "bl_cont1", to, &BLEND_ORDERS.labels())).on_hover_text(BLEND_ORDERS.tip());
|
||||
form_row(ui, "End bulge", |ui| {
|
||||
ui_kit::scalar_field(ui, to_bulge, -0.9..=3.0);
|
||||
});
|
||||
ui_kit::options(ui, "blend_options", &mut [("Stitch to both bodies", stitch)]);
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
form_row(ui, "Continuity", |ui| continuity_segments(ui, continuity, &PATCH_CONTINUITY))
|
||||
.on_hover_text(continuity_tip(PATCH_CONTINUITY.len()));
|
||||
form_row(ui, "Continuity", |ui| BOUNDARY_ORDERS.segments(ui, continuity)).on_hover_text(BOUNDARY_ORDERS.tip());
|
||||
form_row(ui, "Operation", |ui| {
|
||||
let mut op = usize::from(*join);
|
||||
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
|
||||
@@ -565,7 +584,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
let (body, edges) = body_and_edges(items(inputs, i))?;
|
||||
Ok(json!({"body": body, "edges": edges}))
|
||||
};
|
||||
let word = |i: usize| BLEND_CONTINUITY.get(i).map_or("g1", |(_, w)| *w);
|
||||
let word = |i: usize| BLEND_ORDERS.word(i).unwrap_or("g1");
|
||||
let mut p = json!({
|
||||
"from": side(0)?,
|
||||
"to": side(1)?,
|
||||
@@ -579,7 +598,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
need(0, "the curves (sketch curves or edges)")?;
|
||||
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
|
||||
let word = BOUNDARY_ORDERS.word(*continuity).unwrap_or("g0");
|
||||
let across = items(inputs, 1);
|
||||
let picks = items(inputs, 0);
|
||||
let sketch_ids: Vec<String> = picks.curve_ids();
|
||||
@@ -628,7 +647,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
let edges: Vec<Value> = items(inputs, 0).edge_points().into_iter().map(pt).collect();
|
||||
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
|
||||
let mut p = json!({"edges": edges, "body": body});
|
||||
if let Some((_, word)) = PATCH_CONTINUITY.get(*continuity).filter(|_| *continuity > 0) {
|
||||
if let Some(word) = PATCH_ORDERS.word(*continuity).filter(|_| *continuity > 0) {
|
||||
p["continuity"] = json!(word);
|
||||
}
|
||||
let points: Vec<Value> = items(inputs, 1)
|
||||
@@ -866,7 +885,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, seam, stitch } => {
|
||||
let index = |c: &solidcraft_engine::doc::kernel::Continuity| BLEND_CONTINUITY.iter().position(|(_, w)| *w == c.as_str()).unwrap_or(1);
|
||||
let index = |c: &Continuity| BLEND_ORDERS.index(*c).unwrap_or(1);
|
||||
let k = Surf::Blend { continuity: continuity.each_ref().map(index), bulge: *bulge, stitch: *stitch };
|
||||
// What the dialog does not show is kept as it was: the leave directions and the seams.
|
||||
let mut extra = Map::new();
|
||||
@@ -882,8 +901,8 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { sketch, curves, orders, crown, samples, .. } => {
|
||||
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
|
||||
let first = orders.first().copied().unwrap_or(Continuity::G1);
|
||||
let index = BOUNDARY_ORDERS.index(first);
|
||||
// What the dialog does not show is kept as it was: a crown, the stations, and orders
|
||||
// that differ from side to side or that the list does not offer (G3).
|
||||
let mut extra = Map::new();
|
||||
@@ -924,8 +943,8 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
|
||||
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(Continuity::G0, |c| c.continuity);
|
||||
let index = BOUNDARY_ORDERS.index(g).unwrap_or(0);
|
||||
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections, rails, rail_fit, .. } => {
|
||||
@@ -957,7 +976,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: sk.0, index }));
|
||||
}
|
||||
}
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == continuity.all.as_str()).unwrap_or(0);
|
||||
let index = PATCH_ORDERS.index(continuity.all).unwrap_or(0);
|
||||
// What the dialog does not show is kept as it was: the crown, edges with a
|
||||
// continuity of their own, and points that are not on a face any more.
|
||||
let through: Vec<Sel> = points.iter().filter_map(|p| crate::dialogs::face_sel(s, p.0)).collect();
|
||||
@@ -1103,3 +1122,40 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
}
|
||||
Some((Kind::Surface(k), ins, Map::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whether `text` names a continuity by its code (G0 … G4).
|
||||
fn coded(text: &str) -> bool {
|
||||
Continuity::ALL.iter().any(|g| text.contains(g.code()))
|
||||
}
|
||||
|
||||
/// The Patch, Blend and Boundary rows offer what their construction builds, from G0; their
|
||||
/// labels are words in every complete catalogue (and the RTL ones), never a code, and each
|
||||
/// hover line names its order's code for those who know it.
|
||||
#[test]
|
||||
fn continuity_rows_are_words_and_keep_the_codes_for_the_hover_text() {
|
||||
for orders in [PATCH_ORDERS, BLEND_ORDERS, BOUNDARY_ORDERS] {
|
||||
let all = orders.all();
|
||||
assert_eq!(all, orders.0.offered());
|
||||
assert_eq!(all.first(), Some(&Continuity::G0), "{orders:?}");
|
||||
for (i, g) in all.iter().enumerate() {
|
||||
assert_eq!(orders.word(i), Some(g.as_str()));
|
||||
assert_eq!(orders.index(*g), Some(i));
|
||||
}
|
||||
assert_eq!(orders.word(all.len()), None);
|
||||
}
|
||||
assert_eq!(BLEND_ORDERS.all().last(), Some(&Continuity::G3), "a blend builds up to G3 today");
|
||||
for lang in ["en", "ar", "he", "ja", "zh", "de", "ko"] {
|
||||
let locale = crate::i18n::Locale::for_language(lang);
|
||||
for g in Continuity::ALL {
|
||||
let (label, tip) = (locale.text(g.label_id()), locale.text(g.tip_id()));
|
||||
assert!(label != g.label_id() && tip != g.tip_id(), "{lang}: {g:?} has no text");
|
||||
assert!(!coded(&label), "{lang}: the {g:?} label names a code: {label}");
|
||||
assert!(tip.contains(g.code()), "{lang}: the {g:?} hover text names its code: {tip}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ use crate::icons::markers::glyph_icon;
|
||||
use crate::icons::markers::glyph_letter;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use egui::{Pos2, Stroke};
|
||||
use egui::{Color32, Pos2, Stroke};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||||
use solidcraft_engine::kernel::Continuity;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::tool_modifiers::{Audit, Binding, Match, ModAction, ModifierCombo, Scope, ToolModifiers, Trigger};
|
||||
@@ -172,47 +173,70 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A seam from an `inspect.continuity` reply as a marker label: its class and the measure that
|
||||
/// decided it (the gap, the angle between the faces' normals, or the curvature jump).
|
||||
fn continuity_label(app: &SolveApp, seam: &Value) -> String {
|
||||
let deg = |key: &str| seam[key]["value"].as_f64().map(|x| format!("{x:.3}\u{b0}")).unwrap_or_default();
|
||||
let jump = seam["curvature_jump_per_mm"]["value"].as_f64().map(|k| format!("\u{394}k {k:.4}/mm")).unwrap_or_default();
|
||||
match seam["verdict"].as_str().unwrap_or_default() {
|
||||
"gap" => format!("Gap {}", seam["gap_mm"]["value"].as_f64().map(|g| crate::prefs::show_mm(app, g, 1)).unwrap_or_default()),
|
||||
"sharp" => format!("{} {}", crate::i18n::tr("ui.continuity_crease"), deg("normal_angle_deg")),
|
||||
"curvature_jump" => format!("{} {} {jump}", crate::i18n::tr("ui.continuity_tangent"), deg("normal_angle_deg")),
|
||||
"smooth" => format!("{} {} {jump}", crate::i18n::tr("ui.continuity_curvature"), deg("normal_angle_deg")),
|
||||
other => other.to_string(),
|
||||
/// The message id of the class a seam whose highest order met is `order` reads as: a gap, a
|
||||
/// crease (G0 only), or the word of its order (Tangent … Smooth curvature change).
|
||||
pub(crate) fn order_class(order: Option<Continuity>) -> &'static str {
|
||||
match order {
|
||||
None => "ui.continuity_gap",
|
||||
Some(Continuity::G0) => "ui.continuity_crease",
|
||||
Some(g) => g.label_id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Continuity Analysis legend in the viewport's lower left corner: each class in its
|
||||
/// colour, while seams are shaded.
|
||||
/// The measure a seam of `order` fails the next order on: its reply key, symbol and unit.
|
||||
fn next_measure(order: Continuity) -> Option<(&'static str, &'static str, &'static str)> {
|
||||
match order {
|
||||
Continuity::G1 => Some(("curvature_jump_per_mm", "\u{394}k", "/mm")),
|
||||
Continuity::G2 => Some(("curvature_change_jump_per_mm2", "\u{394}k\u{2032}", "/mm\u{b2}")),
|
||||
Continuity::G3 => Some(("curvature_change_rate_jump_per_mm3", "\u{394}k\u{2033}", "/mm\u{b3}")),
|
||||
Continuity::G0 | Continuity::G4 => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A seam from an `inspect.continuity` reply as a marker label: its class in words and the
|
||||
/// measure that keeps it from the next order (the gap, the angle between the faces' normals, or
|
||||
/// the jump of the curvature, of its change, or of that change's rate).
|
||||
fn continuity_label(app: &SolveApp, seam: &Value) -> String {
|
||||
let deg = |key: &str| seam[key]["value"].as_f64().map(|x| format!("{x:.3}\u{b0}")).unwrap_or_default();
|
||||
let order = seam["order"].as_str().and_then(Continuity::parse);
|
||||
let class = crate::i18n::tr(order_class(order));
|
||||
match order {
|
||||
None => format!("{class} {}", seam["gap_mm"]["value"].as_f64().map(|g| crate::prefs::show_mm(app, g, 1)).unwrap_or_default()),
|
||||
Some(g) => {
|
||||
let next = next_measure(g)
|
||||
.and_then(|(key, symbol, unit)| seam[key]["value"].as_f64().map(|x| format!(" {symbol} {x:.4}{unit}")))
|
||||
.unwrap_or_default();
|
||||
format!("{class} {}{next}", deg("normal_angle_deg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Continuity Analysis legend in the viewport's lower left corner: each class in its colour
|
||||
/// and width (the curvature-continuous orders share a colour and widen with each order), while
|
||||
/// seams are shaded.
|
||||
fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.session.continuity.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let [smooth, jump, sharp, gap] = tk.continuity;
|
||||
// Stable ids: this Gap is a hole between faces, not the sheet-metal gap (`ui.sheet_gap`).
|
||||
let tr = crate::i18n::tr;
|
||||
let rows = [
|
||||
(tr("ui.continuity_curvature"), smooth),
|
||||
(tr("ui.continuity_tangent"), jump),
|
||||
(tr("ui.continuity_crease"), sharp),
|
||||
(tr("ui.continuity_gap"), gap),
|
||||
];
|
||||
// Best to worst. Stable ids: this Gap is a hole between faces, not the sheet-metal gap.
|
||||
let mut classes: Vec<Option<Continuity>> = Continuity::ALL.into_iter().rev().map(Some).collect();
|
||||
classes.push(None);
|
||||
let rows: Vec<(&str, Color32, f32)> = classes
|
||||
.into_iter()
|
||||
.map(|order| (crate::i18n::tr(order_class(order)), crate::viewport::continuity_color(&tk, order), crate::viewport::seam_width(order)))
|
||||
.collect();
|
||||
let font = egui::FontId::proportional(tk.font_body);
|
||||
let (row, pad, swatch) = (18.0, 8.0, 22.0);
|
||||
let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max);
|
||||
let text_width = rows.iter().map(|(label, ..)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max);
|
||||
let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad);
|
||||
let available = crate::shell::bottom_overlay_rect(app, proj.rect);
|
||||
let frame = egui::Rect::from_min_size(available.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size);
|
||||
painter.rect_filled(frame, tk.radius_sm, tk.panel.gamma_multiply(0.9));
|
||||
crate::scenario::publish_rect("continuity_legend", frame);
|
||||
for (i, (label, col)) in rows.iter().enumerate() {
|
||||
for (i, (label, col, width)) in rows.iter().enumerate() {
|
||||
let y = frame.top() + pad + row * (i as f32 + 0.5);
|
||||
painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(3.0, *col));
|
||||
painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(*width, *col));
|
||||
painter.text(egui::pos2(frame.left() + pad + swatch + 8.0, y), egui::Align2::LEFT_CENTER, *label, font.clone(), tk.text);
|
||||
}
|
||||
}
|
||||
@@ -288,16 +312,16 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
|
||||
}
|
||||
let Ok(v) = app.run(id, json!({})) else { return false };
|
||||
let summary = &v["summary"];
|
||||
// Each class in the legend's words.
|
||||
let counts: Vec<String> = [
|
||||
("smooth", "ui.continuity_curvature"),
|
||||
("curvature_jump", "ui.continuity_tangent"),
|
||||
("sharp", "ui.continuity_crease"),
|
||||
("gap", "ui.continuity_gap"),
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|(key, class)| summary[*key].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {}", crate::i18n::tr(class))))
|
||||
.collect();
|
||||
// Each class in the legend's words, best first.
|
||||
let gaps = summary["gap"].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {}", crate::i18n::tr(order_class(None))));
|
||||
let counts: Vec<String> = Continuity::ALL
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|g| {
|
||||
summary["orders"][g.as_str()].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {}", crate::i18n::tr(order_class(Some(g)))))
|
||||
})
|
||||
.chain(gaps)
|
||||
.collect();
|
||||
app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -458,6 +458,7 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
seam.edge.hash(&mut h);
|
||||
seam.points.len().hash(&mut h);
|
||||
format!("{:?}", seam.verdict).hash(&mut h);
|
||||
seam.order.hash(&mut h);
|
||||
seam.points.first().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]).hash(&mut h);
|
||||
}
|
||||
// So are the faces Isocurve Analysis shows (their curves follow the bodies, hashed above).
|
||||
@@ -1419,10 +1420,11 @@ pub(crate) 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 = sc.color(c4(continuity_color(&tk, seam.verdict)));
|
||||
let col = sc.color(c4(continuity_color(&tk, seam.order)));
|
||||
let width = seam_width(seam.order);
|
||||
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.line(a.to_f32(), b.to_f32(), col, width, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1443,13 +1445,15 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
sc
|
||||
}
|
||||
|
||||
/// The colour of a measured seam, one per verdict from the theme's `continuity` tokens: within
|
||||
/// every tolerance (G2), tangent with a curvature jump (G1), a crease (G0), and faces that do
|
||||
/// not meet.
|
||||
fn continuity_color(t: &Tokens, verdict: solidcraft_engine::kernel::SeamVerdict) -> Color32 {
|
||||
/// The colour of a measured seam whose highest order met is `order`, from the theme's
|
||||
/// `continuity` tokens by its verdict ([`SeamVerdict::of`]): curvature continuous (G2 and up),
|
||||
/// tangent with a curvature jump (G1), a crease (G0), and faces that do not meet.
|
||||
///
|
||||
/// [`SeamVerdict::of`]: solidcraft_engine::kernel::SeamVerdict::of
|
||||
pub(crate) fn continuity_color(t: &Tokens, order: Option<solidcraft_engine::kernel::Continuity>) -> Color32 {
|
||||
use solidcraft_engine::kernel::SeamVerdict as V;
|
||||
let [smooth, jump, sharp, gap] = t.continuity;
|
||||
match verdict {
|
||||
match V::of(order) {
|
||||
V::Smooth => smooth,
|
||||
V::CurvatureJump => jump,
|
||||
V::Sharp => sharp,
|
||||
@@ -1457,6 +1461,13 @@ fn continuity_color(t: &Tokens, verdict: solidcraft_engine::kernel::SeamVerdict)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke width of a measured seam: 2.6 px, and one more for each order past G2 the
|
||||
/// curvature-continuous seams meet (G3, G4), which share G2's colour.
|
||||
pub(crate) fn seam_width(order: Option<solidcraft_engine::kernel::Continuity>) -> f32 {
|
||||
let past_g2 = order.map_or(0, |g| g.order().saturating_sub(2));
|
||||
2.6 + past_g2 as f32
|
||||
}
|
||||
|
||||
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
|
||||
fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
|
||||
let tk = Tokens::get();
|
||||
|
||||
Reference in New Issue
Block a user