Isocurve Analysis: count curves each way, evenly spaced across the face

The curves were held on a 1-2-5 ladder from the world origin, so count 3 on a 40 x 30 face gave 3
one way and 2 the other. They now follow the command's contract: the held parameter at
lo + k (hi - lo) / (count + 1), k = 1…count, over the face's extent. Faces of the body on the same
surface record are spaced together over their combined extent, so a face a boss splits still
shows the same lines straight across the split.

New case cov-isocurve-40-by-30-face-count-3: x = 10, 20, 30 and y = 7.5, 15, 22.5, to 1e-9 mm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 20:41:33 -04:00
committed by Irakli Kourtis
parent 16d467e14a
commit ffb1e650df
4 changed files with 154 additions and 91 deletions
+3 -3
View File
@@ -347,9 +347,9 @@ impl ModelBody {
self.mesh.get().cloned()
}
/// The isoparametric curves of face `face` for Isocurve Analysis: spaced by `count` on the
/// face's surface (so faces on one surface share them), exact and cut to its trims
/// ([`kernel::iso_grid`]), their points at the chord
/// The isoparametric curves of face `face` for Isocurve Analysis: `count` each way, evenly
/// spaced across the face (faces on one surface spaced together, so they share them), exact
/// and cut to its trims ([`kernel::iso_grid`]), their points at the chord
/// tolerance of the viewport's display quality. Worked out once per face, count and display
/// quality, and shared by every clone of this body (timeline snapshots, world states). A mesh
/// body has no surfaces, so none.
+22 -88
View File
@@ -191,14 +191,14 @@ pub fn iso_curves(body: &Body, face: usize, at: Vec3, along_u: bool) -> Result<V
Ok(out)
}
/// The isoparametric curves of face `face` of `body` for Isocurve Analysis, the held parameter
/// on a ladder of the face's surface ([`iso_ladder`]) rather than on the face's own extent, so
/// faces on one surface (a face split by a boss, the pieces of a split cylinder) show the same
/// lines, straight across where they meet. `count` (1…[`MAX_ISO_COUNT`]) sets the density: a
/// bounded parameter's range in `count + 1` steps, a linear one in steps of the body's size over
/// `count + 1`, rounded to 1, 2 or 5 times a power of ten. The rungs strictly inside the face's
/// extent are cut to the face ([`FaceTrims::clip`]), their points at chord tolerance `chord`.
/// Along u first, then v.
/// The isoparametric curves of face `face` of `body` for Isocurve Analysis: `count`
/// (1…[`MAX_ISO_COUNT`]) curves each way, evenly spaced across the face, the held parameter at
/// `lo + k · (hi − lo) / (count + 1)` for `k = 1…count` over the face's extent in (u, v), so
/// neither side of the extent (the face's boundary) is drawn as a curve. Faces of the body on the
/// very same surface (a face a boss splits in two) are spaced together, across the extent of all
/// of them, so their curves are the same lines, straight across where they meet. Each curve is
/// cut to the face ([`FaceTrims::clip`]), its points at chord tolerance `chord`. Along u first,
/// then v.
pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Vec<IsoLine>> {
if !(1..=MAX_ISO_COUNT).contains(&count) {
return Err(KernelError::Invalid(format!("iso curves: 1…{MAX_ISO_COUNT} each way")));
@@ -207,28 +207,15 @@ pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Ve
return Err(KernelError::Invalid("iso curves: the chord tolerance must be positive".into()));
}
let trims = FaceTrims::new(body, face)?;
let spacing = round_spacing(body.size() / (count + 1) as f64)
.ok_or_else(|| KernelError::Invalid("iso curves: the body has no size to space them by".into()))?;
let [[u0, u1], [v0, v1]] = trims.extent();
let [[u0, u1], [v0, v1]] = shared_extent(body, face, &trims)?;
let mut out = Vec::new();
for along_u in [true, false] {
let (lo, hi) = if along_u { (v0, v1) } else { (u0, u1) };
if !(hi > lo) {
continue;
}
let Some((start, step)) = iso_ladder(trims.surface, &trims.eval, !along_u, count, spacing) else { continue };
// Rungs on the extent's sides are its boundary, not curves across it.
let eps = step * 1e-6;
let first = ((lo + eps - start) / step).ceil();
let last = ((hi - eps - start) / step).floor();
if !(first.is_finite() && last.is_finite()) {
continue;
}
// The ladder is spaced so that a face holds at most about `count + 1` rungs; the cap
// only guards a surface whose parameters run far faster than the body's size.
let rungs = (last - first + 1.0).clamp(0.0, (4 * MAX_ISO_COUNT) as f64) as usize;
for k in 0..rungs {
let fixed = start + (first + k as f64) * step;
for k in 1..=count {
let fixed = lo + (hi - lo) * k as f64 / (count + 1) as f64;
for (curve, interval) in trims.clip(along_u, fixed)? {
let segs = curve_path(&curve, interval)?;
let mut points: Vec<Vec3> = segs.first().map(crate::PathSeg::start).into_iter().collect();
@@ -242,72 +229,19 @@ pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Ve
Ok(out)
}
/// Where the iso lines of a surface fall in one of its parameters (`held_u`: u, else v):
/// `start + k * step` for whole k, fixed by the surface alone. A bounded parameter (an angle, a
/// B-spline's knot range) is its range in `count + 1` steps from its start. A linear one (a
/// plane's, a cylinder's or cone's along its axis) has rungs `spacing` apart in the world,
/// counted from the world origin along the parameter's gradient, so another record of the same
/// plane or axis (its own origin, its own face) gets the same lines. None for a parameter that is
/// neither.
fn iso_ladder(surface: &brep::Surface, eval: &SurfaceEval, held_u: bool, count: usize, spacing: f64) -> Option<(f64, f64)> {
use solidcraft_geom::brep::Surface;
let d = if held_u { 0 } else { 1 };
if let Some((a, b)) = eval.domain().range.get(d).copied().flatten().filter(|(a, b)| b > a) {
return Some((a, (b - a) / (count + 1) as f64));
}
let v = |q: [f64; 3]| Vec3::new(q[0], q[1], q[2]);
// The parameter as (P - origin) · g, P a point of the surface.
let (origin, g) = match surface {
Surface::Plane { origin, u_axis, v_axis } => (v(*origin), plane_gradient(v(*u_axis), v(*v_axis), held_u)?),
Surface::PlaneThrough { origin, u_point, v_point } => {
let o = v(*origin);
(o, plane_gradient(v(*u_point) - o, v(*v_point) - o, held_u)?)
/// The extent in (u, v) of face `face` together with every other face of `body` on the same
/// surface record (the same surface and parameters, so one (u, v) for all of them): the box round
/// all their extents ([`FaceTrims::extent`]).
fn shared_extent(body: &Body, face: usize, trims: &FaceTrims) -> Result<[[f64; 2]; 2]> {
let mut b = trims.extent();
for (i, other) in body.record().faces.iter().enumerate() {
if i == face || other.parameters() != trims.surface {
continue;
}
// Along the axis: P = origin + z * v + (radial part, at right angles to z).
Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } if !held_u => {
let z = v(frame.x_axis).cross(v(frame.y_axis));
let zz = z.dot(z);
if !(zz > 1e-300) {
return None;
}
(v(frame.origin), z * (1.0 / zz))
}
_ => return None,
};
let len = g.len();
if !(len.is_finite() && len > 0.0) {
return None;
let [[a0, a1], [c0, c1]] = FaceTrims::new(body, i)?.extent();
b = [[b[0][0].min(a0), b[0][1].max(a1)], [b[1][0].min(c0), b[1][1].max(c1)]];
}
// The parameter of the world plane P · g/|g| = k * spacing is k * spacing * |g| - origin · g.
Some((-origin.dot(g), spacing * len))
}
/// The gradient of a plane's u (`held_u`) or v in the world: the dual of its axes in their plane.
fn plane_gradient(du: Vec3, dv: Vec3, held_u: bool) -> Option<Vec3> {
let n = du.cross(dv);
let nn = n.dot(n);
if !(nn > 1e-300) {
return None;
}
Some(if held_u { dv.cross(n) } else { n.cross(du) } * (1.0 / nn))
}
/// `x` rounded down to 1, 2 or 5 times a power of ten (None unless it is positive and finite).
fn round_spacing(x: f64) -> Option<f64> {
if !(x.is_finite() && x > 0.0) {
return None;
}
let p = 10f64.powf(x.log10().floor());
let m = x / p;
Some(
p * if m >= 5.0 {
5.0
} else if m >= 2.0 {
2.0
} else {
1.0
},
)
Ok(b)
}
/// A pcurve's parameters and points at [`ISO_SAMPLES`] per knot span, in its direction.
@@ -0,0 +1,102 @@
{
"sources": [],
"what": "three isocurves each way across a 40 x 30 face",
"source": "analytic",
"method": "box 40 x 30 x 10 with a corner at the origin: its top is the plane z = 10, x 0..40 and y 0..30. count 3 curves each way, evenly spaced across it (the command's contract), divide each side into 4: x = 10, 20, 30 (each from y 0 to 30) and y = 7.5, 15, 22.5 (each from x 0 to 40), every curve the straight line across the face. In the order the reply lists them (along the surface's first parameter first, as its parameters run), to 1e-9 mm",
"results": [
{
"step": 2,
"pointer": "/faces/0/count",
"json": 3
},
{
"step": 2,
"pointer": "/faces/0/u_curves",
"json": 3
},
{
"step": 2,
"pointer": "/faces/0/v_curves",
"json": 3
},
{
"step": 2,
"pointer": "/curves",
"json": [
[
[
30.0,
0.0,
10.0
],
[
30.0,
30.0,
10.0
]
],
[
[
20.0,
0.0,
10.0
],
[
20.0,
30.0,
10.0
]
],
[
[
10.0,
0.0,
10.0
],
[
10.0,
30.0,
10.0
]
],
[
[
40.0,
7.5,
10.0
],
[
0.0,
7.5,
10.0
]
],
[
[
40.0,
15.0,
10.0
],
[
0.0,
15.0,
10.0
]
],
[
[
40.0,
22.5,
10.0
],
[
0.0,
22.5,
10.0
]
]
],
"abs_mm": 1e-09
}
]
}
@@ -0,0 +1,27 @@
{
"commands": [
{
"command": "solid.box",
"params": {
"length": 40,
"width": 30,
"height": 10,
"body_name": "Slab",
"operation": "new"
}
},
{
"command": "inspect.isocurve",
"params": {
"faces": [
[
20,
15,
10
]
],
"count": 3
}
}
]
}