forked from 1b-agent/opencadstudio-upstream
fix(tess): sample a curve against its own size, not an absolute length
Round things came out with too few sides in drawings measured in metres, and zooming in did not help. The chord tolerance was floored at five thousandths of a world unit. That is a fine sampling for a part measured in millimetres and a coarse one for a building measured in metres: a ten-centimetre pipe got about ten sides and a five-centimetre bolt about seven. The floor was also what stopped the frame asking for better, so closing the camera on one changed nothing. A chord height is an absolute length, so any fixed value carries an assumption about the drawing's units. Bounded against the curve's own radius instead, which carries none. The two ends are really segment counts: a circle gets at least about fifty sides and never more than about a hundred and sixty, wherever the camera is and whatever the drawing is measured in. Between them the frame still decides, so a curve fills in as the camera closes on it. Straight runs keep the frame's own tolerance — there is no curvature to sample, and two ends are all a line has. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
35a2714025
commit
afb4fd4733
2 files changed
+122
-1
No files matched your search
+34
-1
@@ -344,7 +344,40 @@ pub fn entity_curve_xy(entity: &EntityType) -> Option<Curve> {
|
||||
/// pair; casting local coordinates on the way in throws away precision the
|
||||
/// shader was built to reconstruct.
|
||||
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
|
||||
curve.tessellate_within(crate::scene::convert::curve_tol::current_curve_tol())
|
||||
let tolerance = crate::scene::convert::curve_tol::curve_tol_for(curve_size(&curve.curve));
|
||||
curve.tessellate_within(tolerance)
|
||||
}
|
||||
|
||||
/// How big a curve is, for deciding how finely to sample it.
|
||||
///
|
||||
/// A radius where there is one, since that is what the chord-height relation
|
||||
/// is written in terms of. `0` for the kinds whose sampling density is not a
|
||||
/// question of curvature — a straight run needs its two ends and nothing in
|
||||
/// between — which leaves those on the frame's own tolerance.
|
||||
fn curve_size(curve: &Curve) -> f64 {
|
||||
match curve {
|
||||
Curve::Circle(circle) => circle.radius,
|
||||
Curve::Arc(arc) => arc.radius,
|
||||
Curve::Ellipse(arc) => arc.ellipse.major_radius,
|
||||
// A polyline's bulges and a spline's bends are the same question asked
|
||||
// of a chain, and its own extent is the nearest thing to a radius.
|
||||
Curve::Polyline(_) | Curve::Nurbs(_) => {
|
||||
let sampled = curve.tessellate(64.0);
|
||||
let (mut low, mut high) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for point in &sampled {
|
||||
for axis in 0..2 {
|
||||
low[axis] = low[axis].min(point[axis]);
|
||||
high[axis] = high[axis].max(point[axis]);
|
||||
}
|
||||
}
|
||||
if low[0] > high[0] {
|
||||
0.0
|
||||
} else {
|
||||
(high[0] - low[0]).max(high[1] - low[1]) * 0.5
|
||||
}
|
||||
}
|
||||
Curve::Line(_) | Curve::Ray(_) | Curve::XLine(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The snap candidates an entity's curve offers, in the two channels the
|
||||
|
||||
@@ -32,6 +32,38 @@ pub fn set_curve_tol_override(tol: Option<f64>) {
|
||||
CURVE_TOL_BITS.store(bits, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The tolerance to sample a curve of a given size at.
|
||||
///
|
||||
/// A chord tolerance is an absolute length, so a fixed floor carries an
|
||||
/// assumption about the drawing's units. Five thousandths is a fine sampling
|
||||
/// for a part measured in millimetres and a coarse one for a building
|
||||
/// measured in metres — it turns a ten-centimetre pipe into a decagon and a
|
||||
/// five-centimetre bolt into a heptagon, and zooming in cannot recover them,
|
||||
/// because the floor is what stops the frame asking for better.
|
||||
///
|
||||
/// Bounded against the curve's own size instead, which carries no unit. The
|
||||
/// two ends are worth stating as segment counts, since that is what they
|
||||
/// really are: a circle gets at least about fifty sides and never more than
|
||||
/// about a hundred and sixty, wherever the camera is and whatever the drawing
|
||||
/// is measured in.
|
||||
pub(crate) fn curve_tol_for(size: f64) -> f64 {
|
||||
if !(size > 0.0) || !size.is_finite() {
|
||||
return current_curve_tol();
|
||||
}
|
||||
// The frame's own request, before the absolute floor — that floor is the
|
||||
// very thing being replaced here, and letting it through first would pin
|
||||
// every curve to it however close the camera came.
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
let asked = if bits == 0 {
|
||||
// Nothing is being drawn — a load, a snap, a hit test. Ask for the
|
||||
// middle of the range, which is smooth without being extravagant.
|
||||
size / 1_500.0
|
||||
} else {
|
||||
f64::from_bits(bits)
|
||||
};
|
||||
asked.clamp(size / 5_000.0, size / 500.0)
|
||||
}
|
||||
|
||||
/// The tolerance to sample at, never below the floor — so zooming a long way
|
||||
/// in cannot ask for a sampling finer than the baseline quality.
|
||||
pub(crate) fn current_curve_tol() -> f64 {
|
||||
@@ -50,3 +82,59 @@ pub(crate) fn active_curve_tol() -> Option<f64> {
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
(bits != 0).then(|| f64::from_bits(bits).max(CURVE_TOL))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// How many chords a circle of `radius` needs at chord height `tol`.
|
||||
///
|
||||
/// From the sagitta: a chord subtending `θ` departs from the arc by
|
||||
/// `r(1 − cos(θ/2))` in the middle.
|
||||
fn sides(radius: f64, tol: f64) -> f64 {
|
||||
let step = 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos();
|
||||
std::f64::consts::TAU / step
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_gets_the_same_number_of_sides_whatever_it_is_measured_in() {
|
||||
// The fault: the floor was an absolute length, so the same circle drawn
|
||||
// in millimetres and in metres came out smooth and faceted. Ten
|
||||
// centimetres of pipe had ten sides.
|
||||
set_curve_tol_override(None);
|
||||
let in_millimetres = sides(100.0, curve_tol_for(100.0));
|
||||
let in_metres = sides(0.1, curve_tol_for(0.1));
|
||||
assert!(
|
||||
(in_millimetres - in_metres).abs() < 1.0,
|
||||
"{in_millimetres} vs {in_metres}"
|
||||
);
|
||||
assert!(in_metres > 40.0, "a circle should not read as a decagon: {in_metres}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_buys_detail_and_zooming_out_does_not_cost_it() {
|
||||
// Between the two bounds the frame decides, so a curve fills in as the
|
||||
// camera closes on it.
|
||||
set_curve_tol_override(Some(0.01));
|
||||
let far = sides(1.0, curve_tol_for(1.0));
|
||||
set_curve_tol_override(Some(0.0001));
|
||||
let near = sides(1.0, curve_tol_for(1.0));
|
||||
assert!(near > far, "{near} vs {far}");
|
||||
// And neither end runs away: a circle is never a polygon and never a
|
||||
// thousand-sided one that costs more than it shows.
|
||||
set_curve_tol_override(Some(1e-12));
|
||||
assert!(sides(1.0, curve_tol_for(1.0)) < 200.0);
|
||||
set_curve_tol_override(Some(1e9));
|
||||
assert!(sides(1.0, curve_tol_for(1.0)) > 40.0);
|
||||
set_curve_tol_override(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_with_no_size_is_left_to_the_frame() {
|
||||
// A straight run needs its two ends; there is no curvature to sample.
|
||||
set_curve_tol_override(Some(0.02));
|
||||
assert_eq!(curve_tol_for(0.0), current_curve_tol());
|
||||
assert_eq!(curve_tol_for(f64::NAN), current_curve_tol());
|
||||
set_curve_tol_override(None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user