Isocurve Analysis: faces on one surface share their lines

The held parameter was spaced over each face's own trimmed (u, v) box, so the pieces of a
face split by a boss showed different lines that missed each other at the split. The lines
now sit on a ladder of the surface: a bounded parameter's range in count + 1 steps, a linear
one (a plane's, a cylinder's or cone's axis) at a round world spacing from the world origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-28 10:54:45 -04:00
committed by Irakli Kourtis
parent e4844753cb
commit 912bccef65
3 changed files with 131 additions and 8 deletions
+3 -2
View File
@@ -334,8 +334,9 @@ impl ModelBody {
self.mesh.get().cloned()
}
/// The isoparametric curves of face `face` for Isocurve Analysis: `count` each way across
/// the face, exact and cut to its trims ([`kernel::iso_grid`]), their points at the chord
/// 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
/// 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.
+91 -6
View File
@@ -191,10 +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: `count` each way
/// (1…[`MAX_ISO_COUNT`]), the held parameter evenly spaced across the face's extent in (u, v)
/// with the extent's sides left out (`k / (count + 1)` of the way, k = 1…count), each cut to the
/// face ([`FaceTrims::clip`]) and its points at chord tolerance `chord`. Along u first, then v.
/// 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.
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")));
@@ -203,6 +207,8 @@ 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 mut out = Vec::new();
for along_u in [true, false] {
@@ -210,8 +216,19 @@ pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Ve
if !(hi > lo) {
continue;
}
for k in 1..=count {
let fixed = lo + (hi - lo) * k as f64 / (count + 1) as f64;
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 (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();
@@ -225,6 +242,74 @@ 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)?)
}
// 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;
}
// 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
},
)
}
/// A pcurve's parameters and points at [`ISO_SAMPLES`] per knot span, in its direction.
fn pcurve_samples(c: &brep::Curve2) -> Vec<(f64, [f64; 2])> {
let Some([a, b]) = c.interval() else { return Vec::new() };
+37
View File
@@ -5986,6 +5986,43 @@ mod tests {
assert_eq!(accent(&after.lines, &after), 0, "the curves go with the analysis");
}
/// Isocurve Analysis on a face a boss splits in two: both pieces lie on one plane, so their
/// curves are the same lines, straight across the boss (they were spaced over each piece's
/// own extent, so the two pieces' lines missed each other).
#[test]
fn isocurves_of_a_split_face_run_on_across_the_split() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length": 60, "width": 40, "height": 30, "center": [0, 0, 15]})).unwrap();
// Through the front face from bottom to top: the face is left in two pieces, x < -2 and x > 22.
h.app.run("solid.cylinder", json!({"radius": 12, "height": 50, "base": [10, -20, 0], "operation": "join"})).unwrap();
h.settle();
h.app.run("inspect.isocurve", json!({"faces": [[-16, -20, 15], [26, -20, 15]]})).unwrap();
let faces = h.app.session.isocurve_lines();
assert_eq!(faces.len(), 2, "{:?}", h.app.status);
// Each piece's lines up the face (x held) and across it (z held), by where they are held.
let held = |lines: &[solidcraft_engine::kernel::IsoLine], k: usize| {
let mut at: Vec<f64> = lines
.iter()
.filter_map(|l| {
let (a, b) = (l.points.first()?, l.points.last()?);
let (pa, pb) = ([a.x, a.y, a.z][k], [b.x, b.y, b.z][k]);
((pa - pb).abs() < 1e-9).then_some(pa)
})
.collect();
at.sort_by(f64::total_cmp);
at
};
for (k, axis) in [(0, "x"), (2, "z")] {
let (left, right) = (held(&faces[0], k), held(&faces[1], k));
assert!(left.len() >= 2 && !right.is_empty(), "{axis}: {left:?} {right:?}");
let step = left[1] - left[0];
for x in left.iter().chain(&right) {
let rung = (x - left[0]) / step;
assert!((rung - rung.round()).abs() < 1e-6, "{axis} = {x} is off the ladder {left:?} (step {step}); right piece {right:?}");
}
}
}
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies
/// built into triangles on the CPU: the sample design rendered both ways through the
/// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one).