feat(tess): use angular sampling

Route curve, hatch, sweep, loft, surface, edge, and isoline tessellation through the shared kernel policy.
This commit is contained in:
Hakan Seven committed 2026-08-10 12:12:16 +03:00
1 parent f607422c9d
commit 96be8214f9
21 files changed
+158 -767

No files matched your search

+2 -8
View File
@@ -1961,17 +1961,11 @@ fn transform_tangent(
}
}
/// Radius / coordinate cap above which adaptive curve tessellation will
/// allocate hundreds of millions of points. `parameter_division` samples
/// to a fixed chord tolerance, so a Circle of radius 1e10 already produces
/// tens of millions of points.
/// Coordinate cap for invalid or impractical extents.
const SANE_EXTENT: f64 = 1.0e8;
fn is_unreasonable_extent(e: &EntityType) -> bool {
// Adaptive curve tessellation also explodes on degenerate primitives
// (radius = 0, axes of length 0): `parameter_division` allocates
// proportional to range/tolerance, which underflows when the curve
// collapses to a point. Drop both ends of the spectrum.
// Drop degenerate primitives and impractical coordinate ranges.
match e {
EntityType::Circle(c) => c.radius.abs() < 1.0e-9 || c.radius.abs() > SANE_EXTENT,
EntityType::Arc(a) => a.radius.abs() < 1.0e-9 || a.radius.abs() > SANE_EXTENT,
+7 -18
View File
@@ -15,9 +15,6 @@ use acadrust::kernel::brep;
use crate::scene::convert::solid3d_tess::{body_transform, finalize_mesh};
use crate::scene::model::mesh_model::{CurvedGen, MeshLodSet};
/// Relative chord tolerance, resolved once per body.
const CHORD_FRAC: f64 = 0.002;
/// ACIS topology fit tolerance.
const TOL: f64 = 1e-6;
@@ -56,7 +53,7 @@ pub fn tessellate_sat(
} else {
1.0
};
let frac = CHORD_FRAC / resolution;
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE / resolution;
// Positions stay f64 until `finalize_mesh` splits them into the coarse
// and fine pair, so a solid at survey coordinates keeps its millimetres.
@@ -101,7 +98,7 @@ pub fn tessellate_sat(
// parameters leaves a hole, the same as one that never lifted — so both
// are counted before calling the mesh whole.
let mut undrawn = 0usize;
let tolerance = brep::mesh::TessellationTolerance::relative(frac, TOL)
let tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL)
.with_isolines(isolines);
for body in &bodies {
let tessellation = brep::mesh::tessellate(body, tolerance);
@@ -237,23 +234,15 @@ mod tests {
assert_eq!(moved, [10.0, 20.0, 30.0]);
}
/// How many sides a circle of `radius` gets at a sag of `frac × radius`.
fn sides(frac: f64) -> f64 {
let step = 2.0 * (1.0 - frac).clamp(-1.0, 1.0).acos();
std::f64::consts::TAU / step
fn sides(max_angle: f64) -> f64 {
std::f64::consts::TAU / max_angle
}
#[test]
fn a_round_surface_gets_the_same_sides_whatever_its_size() {
// The fault: `facet_res` is a resolution multiplier and was used as a
// sag in world units. At the default that asked for a whole unit of
// departure, so nothing smaller than a metre subdivided at all and a
// pipe came out as coarse as its own rim.
//
// A fraction of the radius carries no unit, so a bolt and a pipeline
// are sampled alike.
assert!(sides(CHORD_FRAC) > 24.0, "{}", sides(CHORD_FRAC));
assert!(sides(CHORD_FRAC) < 96.0, "{}", sides(CHORD_FRAC));
let angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
assert!(sides(angle) > 24.0, "{}", sides(angle));
assert!(sides(angle) < 96.0, "{}", sides(angle));
}
}
-140
View File
@@ -1,140 +0,0 @@
// How finely a curve is sampled, for the whole of one render frame.
//
// Every curve in a drawing is tessellated to a chord height — how far the
// polyline drawn for it may sit from the curve itself — and the right value
// depends on the zoom. At a metre per pixel a millimetre of error is
// invisible; zoomed to a millimetre per pixel it is the whole picture.
//
// The Scene sets this once per frame from `world_per_pixel`, targeting about
// half a pixel. Every tessellation inside that frame reads the same atomic,
// including the ones running on rayon workers, which is why it is a global
// rather than a parameter: threading a tolerance through every entity
// converter's signature would touch every one of them to say the same thing.
//
// Zero means "no frame is being drawn" — a load, a snap, a hit test — and the
// floor is used instead. That is what `BlockCache::build` expects.
use std::sync::atomic::{AtomicU64, Ordering};
/// The finest a curve is ever sampled, in world units, and the value used
/// when no frame has set one.
const CURVE_TOL: f64 = 0.005;
static CURVE_TOL_BITS: AtomicU64 = AtomicU64::new(0);
/// Sets the per-frame curve tolerance. `None` — or anything not finite and
/// positive — reverts to the floor.
pub fn set_curve_tol_override(tol: Option<f64>) {
let bits = match tol {
Some(value) if value > 0.0 && value.is_finite() => value.to_bits(),
_ => 0,
};
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 {
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
if bits == 0 {
CURVE_TOL
} else {
f64::from_bits(bits).max(CURVE_TOL)
}
}
/// `Some(tol)` only while a frame's override is in force — that is, while
/// something is being drawn rather than loaded, snapped or hit-tested. Hatch
/// boundaries use it to decide whether zoom-adaptive sampling applies at all.
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);
}
}
-1
View File
@@ -2,7 +2,6 @@ pub mod acad_to_render;
pub mod acis_export;
pub mod acis_kernel;
pub mod dgn_linestyle;
pub mod curve_tol;
pub mod tessellate;
pub(crate) mod tess;
pub mod proxy_graphics;
+1 -78
View File
@@ -1,9 +1,7 @@
// Shared helpers used by per-entity tessellation impls in `crate::entities`
// and the dispatcher in `crate::scene::convert::tessellate`.
//
// Anything cross-entity (colour resolution, arc sampling, chord tolerance)
// lives here. Entity-specific helpers (dim format strings, mleader path
// types, etc.) stay with their entity file.
// Cross-entity rendering helpers live here.
use acadrust::types::Color as AcadColor;
use glam::Vec3;
@@ -28,81 +26,6 @@ pub type FallbackGeometry = (
Vec<[f64; 3]>,
);
// ── Arc tessellation helpers ─────────────────────────────────────────────
/// Convert hatch-boundary arc `(start, end, ccw)` into a
/// `(start, signed_span)` ready for the sampling loop. Matches the
/// legacy `(TAU - sa, TAU - ea)` flip used here for years — direction
/// semantics are preserved on real files. (Wrap-through-2π is a known
/// edge case in that convention; do not "fix" it without a wider audit
/// of how upstream writers emit CW boundary arcs.)
///
/// VERIFIED against real AutoCAD output (KSR-039 DWG, CW arc edges with
/// line neighbours): the stored angles of a `ccw = false` arc edge are
/// MIRRORED — the true point is `center + r·(cos(TAU-θ), sin(TAU-θ))`.
/// Sampling the mirrored parameter directly (this function's flip) gives
/// exact endpoint continuity with the adjacent edges (Δ = 0.000000),
/// while interpreting them as true angles lands tens of units away. Any
/// code that *produces* CW boundary arcs (mirror transforms, explode)
/// must therefore store mirrored angles, not geometric ones.
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
const TAU: f64 = std::f64::consts::TAU;
let (sa, ea) = if ccw {
(start, end)
} else {
(TAU - start, TAU - end)
};
(sa, ea - sa)
}
/// Segment count for an arc, targeting `chord_tol_world` chord-height
/// error in world units. Floor 8, cap 512.
///
/// Two production callers:
/// - hatch fill polygon (built at load / on edit) passes a radius-
/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so
/// the polygon stays sharp at extreme zoom-in without re-tessellation.
/// - hatch wire outline (re-tessellated every frame inside the render
/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls
/// from the per-frame override set by `Scene::wires_for_block` so
/// far-out arcs collapse to a handful of segments.
pub fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
arc_segments_floored(radius, span_abs, chord_tol_world, 8)
}
/// Like [`arc_segments`] but with a caller-chosen segment floor. The 2-D wire
/// wants floor 8 so a circle reads as round; a curved-surface grid patch or a
/// short arc wants a lower floor so a small span isn't forced to 8 steps. Cap
/// stays 512.
pub fn arc_segments_floored(radius: f64, span_abs: f64, chord_tol_world: f64, floor: u32) -> u32 {
if span_abs < 1e-9 || radius < 1e-9 {
return floor.max(1);
}
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
((span_abs / max_step).ceil() as u32).clamp(floor, 512)
}
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
/// floor 1 µm so degenerate radii still produce a workable count.
pub fn fill_chord_tol(radius: f64) -> f64 {
(radius * 0.001).max(1e-6)
}
/// Chord tolerance for the per-frame wire outline: pulls the active
/// `curve_tol::set_curve_tol_override` value (Scene sets it to
/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at
/// the current zoom). When no override is active (snap / hit-test
/// passes, load-time builds), falls back to [`fill_chord_tol`] so we
/// never under-sample.
pub fn wire_chord_tol(radius: f64) -> f64 {
match crate::scene::convert::curve_tol::active_curve_tol() {
Some(t) => t.min(fill_chord_tol(radius)).max(1e-9),
None => fill_chord_tol(radius),
}
}
// ── Colour helper ──────────────────────────────────────────────────────────
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
+1 -1
View File
@@ -4,7 +4,7 @@
// EntityType
// ↓ acad_to_render::convert()
// RenderEntity { object: RenderObject, snap_pts, tangent_geoms, key_vertices }
// ↓ curve_tol::tessellate_*()
// ↓
// RenderObject::Lines → WireModel
// RenderObject::Dot → WireModel (a dot sized in pixels)
// RenderObject::Text → WireModel (glyph strokes) + SDF quads
+8 -189
View File
@@ -1398,195 +1398,14 @@ impl Scene {
let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new();
for edge in &path.edges {
match edge {
BoundaryEdge::Polyline(poly) => {
let verts = &poly.vertices;
let count = verts.len();
if count == 0 {
continue;
}
let seg_count = if poly.is_closed {
count
} else {
count.saturating_sub(1)
};
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % count];
let bulge = v0.z;
// Tess in f64 to preserve ~1 cm precision at
// UTM-scale WCS (the f32 path used to produce
// visibly wavy hatch arcs at 1e5+ magnitude).
let arc = if bulge.abs() < 1e-9 {
None
} else {
crate::entities::common::BulgeArc::from_bulge(
[v0.x, v0.y],
[v1.x, v1.y],
bulge,
)
};
let Some(arc) = arc else {
boundary.push(to_xy(v0.x, v0.y));
continue;
};
let segs = convert::tess_util::arc_segments(
arc.radius,
arc.sweep.abs(),
convert::tess_util::fill_chord_tol(arc.radius),
);
for j in 0..segs {
let s = arc.sample(j as f64 / segs as f64);
boundary.push(to_xy(s[0], s[1]));
}
}
if poly.is_closed {
if let Some(&first) = boundary.get(path_start) {
boundary.push(first);
}
}
}
BoundaryEdge::Line(line) => {
edge_polys.push(vec![
to_xy(line.start.x, line.start.y),
to_xy(line.end.x, line.end.y),
]);
}
BoundaryEdge::CircularArc(arc) => {
let (sa, span) = convert::tess_util::arc_signed_span(
arc.start_angle,
arc.end_angle,
arc.counter_clockwise,
);
let segs = convert::tess_util::arc_segments(
arc.radius,
span.abs(),
convert::tess_util::fill_chord_tol(arc.radius),
);
let mut pts = Vec::with_capacity(segs as usize + 1);
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
pts.push(to_xy(
arc.center.x + arc.radius * t.cos(),
arc.center.y + arc.radius * t.sin(),
));
}
edge_polys.push(pts);
}
BoundaryEdge::EllipticArc(ell) => {
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
.sqrt();
let r_min = r_maj * ell.minor_axis_ratio;
let rot = ell
.major_axis_endpoint
.y
.atan2(ell.major_axis_endpoint.x);
let (sa, span) = convert::tess_util::arc_signed_span(
ell.start_angle,
ell.end_angle,
ell.counter_clockwise,
);
let segs = convert::tess_util::arc_segments(
r_maj,
span.abs(),
convert::tess_util::fill_chord_tol(r_maj),
);
let (cr, sr) = (rot.cos(), rot.sin());
let mut pts = Vec::with_capacity(segs as usize + 1);
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
let lx = r_maj * t.cos();
let ly = r_min * t.sin();
pts.push(to_xy(
ell.center.x + lx * cr - ly * sr,
ell.center.y + lx * sr + ly * cr,
));
}
edge_polys.push(pts);
}
BoundaryEdge::Spline(spline) => {
// DXF spline control_points pack (x, y, weight) into
// a Vector3 — the z field is the rational weight, NOT
// a Z coordinate. The legacy code dropped weight and
// sampled with a fixed 16 segments; both bugs
// produced visibly wrong fill regions for spline-
// bounded hatches (especially block-internal ones,
// where boundaries are often spline curves with
// rational weights and short cubic segments).
//
// The kernel's plane NURBS holds the rational and
// the polynomial case alike — weights absent means
// polynomial — and tessellates to a chord tolerance,
// the same one the fill polygon uses for arcs.
let degree = spline.degree.max(0) as usize;
// Rough chord-tolerance: 0.1% of the control-poly
// diagonal so adaptive sampling produces enough
// points to follow the curve without exploding on
// huge splines.
let (mut sp_min_x, mut sp_min_y) = (f64::INFINITY, f64::INFINITY);
let (mut sp_max_x, mut sp_max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for cp in &spline.control_points {
sp_min_x = sp_min_x.min(cp.x);
sp_min_y = sp_min_y.min(cp.y);
sp_max_x = sp_max_x.max(cp.x);
sp_max_y = sp_max_y.max(cp.y);
}
let diag = ((sp_max_x - sp_min_x).powi(2)
+ (sp_max_y - sp_min_y).powi(2))
.sqrt();
let tol = convert::tess_util::fill_chord_tol(diag.max(1.0));
let mut epts: Vec<[f64; 2]> = Vec::new();
let mut sampled = false;
let points: Vec<[f64; 2]> = spline
.control_points
.iter()
.map(|p| [p.x, p.y])
.collect();
let weights = spline.rational.then(|| {
spline
.control_points
.iter()
.map(|p| if p.z.abs() > 1e-12 { p.z } else { 1.0 })
.collect()
});
if let Some(curve) = acadrust::kernel::geom2d::NurbsCurve::new(
degree,
points,
spline.knots.clone(),
weights,
) {
for point in
acadrust::kernel::geom2d::Curve::Nurbs(curve).tessellate_within(tol)
{
epts.push(to_xy(point[0], point[1]));
}
sampled = epts.len() >= 2;
}
if !sampled {
// Fallback: prefer fit_points (which lie on the
// curve) over control_points (which usually
// don't). A control-point polyline would draw
// the convex-hull silhouette — visibly wrong.
let pts: &[_] = if !spline.fit_points.is_empty() {
&spline.fit_points
} else {
&[]
};
if !pts.is_empty() {
for p in pts {
epts.push(to_xy(p.x, p.y));
}
} else {
for cp in &spline.control_points {
epts.push(to_xy(cp.x, cp.y));
}
}
}
edge_polys.push(epts);
}
if let Some(curve) = crate::entities::hatch::edge_curve(edge) {
edge_polys.push(
curve
.tessellate_angle(acadrust::kernel::tessellation::DEFAULT_ANGLE)
.into_iter()
.map(|point| to_xy(point[0], point[1]))
.collect(),
);
}
}
boundary.extend(chain_path_edges(edge_polys));
-15
View File
@@ -8207,21 +8207,6 @@ impl Scene {
let paper = anno_scale_override.is_some();
let blk_cache = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport);
let blk_ref: &cache::block_cache::BlockCache = &blk_cache;
// Zoom-adaptive curve sampling for top-level Edge tessellation. Target
// ~0.5 px chord height — far-out arcs that used to emit hundreds of
// segments now collapse to a handful. The guard clears the override
// when this scope exits so off-render tessellation (snap previews,
// hit-test, block_cache rebuild) sees the default.
struct CurveTolGuard;
impl Drop for CurveTolGuard {
fn drop(&mut self) {
crate::scene::convert::curve_tol::set_curve_tol_override(None);
}
}
let _tol_guard = wpp.map(|w| {
crate::scene::convert::curve_tol::set_curve_tol_override(Some((w * 0.5) as f64));
CurveTolGuard
});
// Per-entity tessellation memo. Same classify/tessellate logic, two
// SEPARATE stores so they can't thrash each other:
// * culled path (`view_aabb == Some`) → `tess_memo`, guard keyed on the
+7 -6
View File
@@ -17,16 +17,17 @@ use acadrust::kernel::brep::{self, Body};
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
/// How far a triangle may sit from the surface it lies on. A drawing is
/// measured in millimetres, and a twentieth of one is past what a screen
/// resolves at any sane zoom.
const SAG: f64 = 0.05;
/// What counts as the same point when the kernel checks a body over.
const TOL: f64 = 1e-9;
fn tessellation(body: &Body) -> brep::mesh::BodyMesh {
brep::mesh::tessellate(body, brep::mesh::TessellationTolerance::new(SAG, TOL))
brep::mesh::tessellate(
body,
brep::mesh::TessellationTolerance::new(
acadrust::kernel::tessellation::DEFAULT_ANGLE,
TOL,
),
)
}
/// Axis-aligned box from its center and full extents.
+7 -7
View File
@@ -112,26 +112,26 @@ use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
/// SWEEP through the kernel's tolerance-driven mesh API.
pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option<MeshLodSet> {
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
let surface = brep::mesh::sweep_surface(
&entity_curve(profile)?,
&entity_curve(path)?,
tolerance,
max_angle,
)?;
mesh_set(surface, color, tolerance)
mesh_set(surface, color, 1e-9)
}
/// LOFT through the kernel's tolerance-driven mesh API.
pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option<MeshLodSet> {
let curves: Vec<PlanarCurve> = profiles.iter().filter_map(entity_curve).collect();
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
mesh_set(brep::mesh::loft_surface(&curves, tolerance)?, color, tolerance)
let max_angle = acadrust::kernel::tessellation::DEFAULT_ANGLE;
mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9)
}
fn mesh_set(
surface: brep::mesh::SurfaceMesh,
color: [f32; 4],
tolerance: f64,
precision: f64,
) -> Option<MeshLodSet> {
if surface.mesh.is_empty() {
return None;
@@ -141,7 +141,7 @@ fn mesh_set(
for point in &surface.mesh.positions {
push_point(&mut verts, &mut verts_low, *point);
}
let silhouette = surface.silhouette_source(tolerance);
let silhouette = surface.silhouette_source(precision);
let mut set = MeshLodSet::from_single(MeshModel {
name: String::new(),
verts,