From 608cf8c74be5456da5b27236798693507ce9f612 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 18 Jul 2026 01:31:46 +0300 Subject: [PATCH] refactor(solid3d): drive curved-face sampling from one chord tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curved 3-D solid faces used fixed per-LOD grid counts, so a partial arc (a sphere cap, a torus C-arc, a short cone wall) was sampled as finely as a whole rim, and the density knobs were scattered across four files. Every curved face (sphere / cone / torus / spline), feature edge and isoline now derives its segment count from a chord-height tolerance plus the arc's own radius and span — the same model the 2-D circle/arc/ellipse wires use (tess_util::arc_segments): a big rim samples finer than a small one, a short arc proportionally less than a full turn, and facet error is size-independent. All tolerances live in one block at the top of solid3d_tess: LodConfig is now a single chord fraction per LOD (arc_segs / circle_segs derive counts), plus EDGE_CHORD_FRAC, TRUCK_CHORD_FRAC and BOUNDARY_CHORD_FRAC. FACETRES scales the fraction. The cone height is a straight generator (no curvature) so it meshes in one division. Removes the scattered constants (circ_segs/grid_u/grid_v, CURVE_REL_TOL, BOUNDARY_SEGS, per-site M / EDGE_SEGS / ring_segs). Adaptive counts come out lower, not higher: the € solid drops 3056→1780 tris, a full sphere stays at 1024, five-solid total is 2989. Co-Authored-By: Claude Opus 4.8 --- src/scene/convert/acis_to_truck.rs | 19 +-- src/scene/convert/solid3d_tess.rs | 233 +++++++++++++++++++---------- src/scene/convert/spline_tess.rs | 7 +- src/scene/convert/tess_util.rs | 18 ++- 4 files changed, 185 insertions(+), 92 deletions(-) diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index c14939ab5..9dc3a5e6c 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -30,22 +30,16 @@ use acadrust::entities::acis::{ use crate::scene::convert::solid3d_tess::{ apply_body_transform, body_transform, collect_face_loops, cone_axis_span, tess_cone_face, - tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig, + tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig, BOUNDARY_CHORD_FRAC, + TRUCK_CHORD_FRAC, }; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; /// Slightly over 2π so revolution builders close the loop. const FULL: f64 = std::f64::consts::TAU + 0.2; -/// Boundary sampling density for planar faces with curved (circular) edges. -const BOUNDARY_SEGS: usize = 64; /// Triangle mesh chord tolerance (world units) for planar faces, where the /// surface itself adds no curvature. const MESH_TOL: f64 = 0.01; -/// Chord tolerance for curved faces, as a fraction of the surface radius, so -/// the facet count is radius-independent instead of exploding on large radii. -/// `0.1` = 10% chord error (~7 facets per full circle) — a deliberately coarse -/// setting that keeps the solid triangle budget low. -const CURVE_REL_TOL: f64 = 0.1; /// Analytic outward-normal rule for a face, used to orient triangles and /// supply smooth per-vertex normals (truck's face orientation is unreliable @@ -154,7 +148,7 @@ fn bespoke_face( match surf_rec.entity_type.as_str() { "plane-surface" => { if let Some(p) = SatPlaneSurface::from_record(surf_rec) { - tess_plane_face(sat, face, &p, LodConfig::HIGH.circ_segs, v, n, i); + tess_plane_face(sat, face, &p, LodConfig::HIGH.chord_frac, v, n, i); } } "cone-surface" => { @@ -185,7 +179,7 @@ fn build_face_group( // Chord tolerance scaled to a curved surface's radius, so the facet count // is radius-independent (matching the circle/arc wire tessellation) rather // than exploding on large radii. - let curve_tol = |radius: f64| (radius.abs() * CURVE_REL_TOL).max(1e-6); + let curve_tol = |radius: f64| (radius.abs() * TRUCK_CHORD_FRAC).max(1e-6); match surf_rec.entity_type.as_str() { "plane-surface" => { let plane = SatPlaneSurface::from_record(surf_rec)?; @@ -230,7 +224,7 @@ fn plane_face(sat: &SatDocument, face: &SatFace) -> Option { // the first wire and cuts the rest as holes, letting truck's mesher trim // them. ACIS records no outer-vs-hole flag — the kernel classifies loops at // runtime from geometry — so we derive the outer boundary here. (#123) - let loops = collect_face_loops(sat, face, BOUNDARY_SEGS); + let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC); if loops.is_empty() { return None; } @@ -343,7 +337,8 @@ fn cone_faces( }; // Angular extent of this face from its boundary loop (seam-robust). - let poly = crate::scene::convert::solid3d_tess::collect_face_polygon(sat, face, BOUNDARY_SEGS); + let poly = + crate::scene::convert::solid3d_tess::collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); let (theta0, sweep) = cone_boundary_arc(&poly, [cx, cy, cz], axis, udir, vdir); // Radial direction at the arc's start angle (u rotated by theta0 about axis). let rad0 = udir * theta0.cos() + vdir * theta0.sin(); diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 8212abec4..2919ae175 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -23,41 +23,91 @@ use acadrust::entities::{Body, Region, Solid3D}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; -/// Per-LOD sampling density. Higher values = finer mesh = more triangles. +// ── Curved-surface sampling — SINGLE TUNING POINT ──────────────────────────── +// +// Every curved 3-D face (sphere / cone / torus / spline), curved feature edge +// and isoline samples at a density derived from a chord-height tolerance — the +// same model the 2-D circle/arc/ellipse wires use (see `tess_util::arc_segments`): +// the segment count tracks the arc's own radius and span at a bounded relative +// chord error, so a partial arc samples proportionally and facet error is +// size-independent. Every knob lives in this block — edit here to trade mesh +// density against the triangle budget across the whole solid tessellator. + +/// Feature edges & isolines are built once at highest detail; this chord-height +/// fraction of the curve radius sets their sampling density (~0.002 ⇒ ~50 +/// segments per full circle). +pub(crate) const EDGE_CHORD_FRAC: f64 = 0.002; +/// Truck's own triangulation chord tolerance for the cone faces still routed +/// through its kernel, as a fraction of the surface radius. +pub(crate) const TRUCK_CHORD_FRAC: f64 = 0.1; +/// Boundary-loop sampling for parameter-range classification (which arc of a +/// sphere/torus a face covers): a fine fraction so the classification is +/// accurate; the points are not rendered. +pub(crate) const BOUNDARY_CHORD_FRAC: f64 = 0.002; + +/// Per-LOD curved-surface sampling tolerance. A LOD is now just a chord-height +/// tolerance (fraction of the local radius); segment counts derive from it plus +/// the arc's own radius and span, so density is adaptive rather than a fixed +/// grid. Smaller fraction = finer mesh = more triangles. #[derive(Copy, Clone, Debug)] pub struct LodConfig { - /// Arc segments per full circle for curved-surface sampling. - pub circ_segs: usize, - /// Longitudinal grid count for sphere / torus surfaces. - pub grid_u: usize, - /// Latitudinal grid count for sphere / torus surfaces. - pub grid_v: usize, + /// Chord-height tolerance as a fraction of the local radius. + pub chord_frac: f64, } impl LodConfig { - /// LOD 0 — full resolution. The pre-Phase-3.4 baseline. - pub const HIGH: LodConfig = LodConfig { - circ_segs: 48, - grid_u: 32, - grid_v: 16, - }; + /// LOD 0 — full resolution (~0.5 % radius ⇒ ~32 segments per full circle, + /// matching the pre-tolerance grid baseline). + pub const HIGH: LodConfig = LodConfig { chord_frac: 0.005 }; /// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal. - pub const MID: LodConfig = LodConfig { - circ_segs: 24, - grid_u: 16, - grid_v: 8, - }; + pub const MID: LodConfig = LodConfig { chord_frac: 0.02 }; /// LOD 2 — quarter-resolution. Use below ~50 px. - pub const LOW: LodConfig = LodConfig { - circ_segs: 12, - grid_u: 8, - grid_v: 4, - }; + pub const LOW: LodConfig = LodConfig { chord_frac: 0.08 }; /// Returns the three LOD configs in `[high, mid, low]` order — matches /// the `MeshLodSet::lods` slot ordering. pub const fn all() -> [LodConfig; 3] { [Self::HIGH, Self::MID, Self::LOW] } + + /// Segment count spanning `span_abs` radians of an arc of `radius` at this + /// LOD's chord tolerance. Floor 2 — an open grid patch needs only a step. + pub fn arc_segs(&self, radius: f64, span_abs: f64) -> usize { + crate::scene::convert::tess_util::arc_segments_floored( + radius.abs(), + span_abs, + radius.abs() * self.chord_frac, + 2, + ) as usize + } + + /// Segment count around a full circle of `radius` at this LOD. Floor 8 so a + /// closed cross-section (a tube / minor circle) still reads as round. + pub fn circle_segs(&self, radius: f64) -> usize { + crate::scene::convert::tess_util::arc_segments_floored( + radius.abs(), + TAU, + radius.abs() * self.chord_frac, + 8, + ) as usize + } +} + +/// Segment count for a feature edge / isoline arc of `radius` spanning +/// `span_abs`, at the shared [`EDGE_CHORD_FRAC`] tolerance. Floor 4. +pub(crate) fn edge_arc_segs(radius: f64, span_abs: f64) -> usize { + crate::scene::convert::tess_util::arc_segments_floored( + radius.abs(), + span_abs, + radius.abs() * EDGE_CHORD_FRAC, + 4, + ) as usize +} + +/// Sample count for a curve with no analytic radius (a spline edge / surface): +/// the unit-circle segment count at chord fraction `frac`, used as a nominal +/// density that still tracks the LOD. +pub(crate) fn nominal_segs(frac: f64) -> usize { + crate::scene::convert::tess_util::arc_segments_floored(1.0, TAU, frac, 8) as usize } // ── Public entry point ──────────────────────────────────────────────────────── @@ -85,7 +135,7 @@ fn tessellate_sat( sat, &face, &plane, - lod.circ_segs, + lod.chord_frac, &mut verts, &mut normals, &mut indices, @@ -266,7 +316,7 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option { let u_dir = norm3([ux, uy, uz]); let v_dir = cross3(axis, u_dir); - let poly = collect_face_polygon(sat, face, 48); + let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); let (mut h_min, mut h_max, mut theta_min, mut theta_max, full) = angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly); if (h_max - h_min).abs() < 1e-9 { @@ -374,18 +424,20 @@ fn sphere_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Ve sphere.u_direction().2, ]); let v = cross3(pole, u); - let poly = collect_face_polygon(sat, face, 48); + let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); let (theta_min, theta_span, full, phi_min, phi_max) = sphere_param_range(&poly, [cx, cy, cz], pole, u, v); - const M: usize = 24; + // Meridian subdivisions from the sphere radius and colatitude span (a great + // circle of radius `r`) at the shared edge chord tolerance. + let m = edge_arc_segs(r, phi_max - phi_min); let sphere_pt = |theta: f64, phi: f64| { let d = sphere_dir(pole, u, v, theta, phi); [cx + r * d[0], cy + r * d[1], cz + r * d[2]] }; for theta in iso_params(theta_min, theta_span, full, count) { - for m in 0..M { - let p0 = phi_min + (phi_max - phi_min) * (m as f64 / M as f64); - let p1 = phi_min + (phi_max - phi_min) * ((m + 1) as f64 / M as f64); + for k in 0..m { + let p0 = phi_min + (phi_max - phi_min) * (k as f64 / m as f64); + let p1 = phi_min + (phi_max - phi_min) * ((k + 1) as f64 / m as f64); out.push(sphere_pt(theta, p0)); out.push(sphere_pt(theta, p1)); } @@ -415,11 +467,12 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec let phi_total = if full { TAU } else { phi_span }; // Minor (cross-section) circles — constant revolution angle, full tube. - const M: usize = 20; + // Segment count from the tube (minor) radius at the shared edge tolerance. + let m = edge_arc_segs(minor, TAU); for phi in iso_params(phi_min, phi_span, full, count) { - for t in 0..M { - let t0 = TAU * (t as f64 / M as f64); - let t1 = TAU * ((t + 1) as f64 / M as f64); + for t in 0..m { + let t0 = TAU * (t as f64 / m as f64); + let t1 = TAU * ((t + 1) as f64 / m as f64); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t0, phi)); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi)); } @@ -429,7 +482,7 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec // revolution arc. The outer (θ=0) and inner (θ=π) circles are the torus's // defining profile — the ring outline; without them it reads as disconnected // cross-sections. `count.max(2)` guarantees outer + inner even at ISOLINES=1. - let ring_segs = (((phi_total / TAU) * 64.0).ceil() as usize).max(6); + let ring_segs = edge_arc_segs(major, phi_total).max(2); let n_ring = count.max(2); for k in 0..n_ring { let theta = TAU * (k as f64 / n_ring as f64); @@ -535,7 +588,7 @@ pub(crate) fn torus_phi_range( let Some(sl) = SatLoop::from_record(lr) else { break; }; - let poly = collect_loop_polygon(sat, &sl, 48); + let poly = collect_loop_polygon(sat, &sl, BOUNDARY_CHORD_FRAC); lp = sl.next_loop(); if poly.is_empty() { continue; @@ -691,7 +744,7 @@ fn collect_curved_gens( sphere.u_direction().2, ]); let v = cross3(pole, u); - let poly = collect_face_polygon(sat, &face, 48); + let poly = collect_face_polygon(sat, &face, BOUNDARY_CHORD_FRAC); let (tmin, tspan, full, pmin, pmax) = sphere_param_range(&poly, [cx, cy, cz], pole, u, v); let (center, center_low) = place([cx, cy, cz]); @@ -746,7 +799,6 @@ fn collect_curved_gens( /// their two vertex endpoints; ellipse/circle edges are sampled along their /// bounded parametric arc. Points are in body-local space (pre-transform). fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> { - const EDGE_SEGS: usize = 48; let mut out: Vec<[f64; 3]> = Vec::new(); for er in sat.records_of_type("edge") { let Some(edge) = SatEdge::from_record(er) else { @@ -761,7 +813,7 @@ fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> { &ellipse, edge.start_param(), edge.end_param(), - EDGE_SEGS, + EDGE_CHORD_FRAC, reversed, ); // sample_ellipse_arc drops the end param; append the true end so @@ -774,7 +826,11 @@ fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> { // chord the fallback below would draw (or nothing, for a closed // loop whose endpoints coincide). pts = ic - .sample_range(edge.start_param(), edge.end_param(), EDGE_SEGS) + .sample_range( + edge.start_param(), + edge.end_param(), + nominal_segs(EDGE_CHORD_FRAC), + ) .into_iter() .map(|(x, y, z)| [x, y, z]) .collect(); @@ -900,15 +956,13 @@ pub(crate) fn apply_body_transform(mesh: &mut MeshModel, m: &[f64; 9], tr: &[f64 } } -/// Scale a LOD's segment counts by FACETRES (clamped to AutoCAD's -/// documented [0.01, 10.0] range). 1.0 is the unchanged baseline. +/// Tighten/loosen a LOD's chord tolerance by FACETRES (clamped to the +/// documented [0.01, 10.0] range). A higher FACETRES means a finer mesh, so it +/// *divides* the chord fraction; 1.0 is the unchanged baseline. fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig { - let m = (facet_res.clamp(0.01, 10.0) as f32).max(0.01); - let scale = |v: usize| ((v as f32) * m).round().max(4.0) as usize; + let m = facet_res.clamp(0.01, 10.0); LodConfig { - circ_segs: scale(base.circ_segs), - grid_u: scale(base.grid_u), - grid_v: scale(base.grid_v), + chord_frac: (base.chord_frac / m).clamp(1e-4, 0.5), } } @@ -1032,15 +1086,16 @@ pub fn tessellate_solid3d( /// Straight edges contribute their start vertex; curved (ellipse / circle) /// edges are sampled into several points so circular boundaries — e.g. the /// cap of a cylinder or the rim of a cone — produce a real polygon instead of -/// a single degenerate vertex. `circ_segs` is the sample count for a full -/// circle (scaled down for shorter arcs). +/// a single degenerate vertex. `chord_frac` is the chord-height tolerance (as a +/// fraction of each edge's own radius); the segment count per edge derives from +/// it plus that edge's radius and span. /// /// Returns an empty `Vec` when the loop topology is broken or has fewer than /// three distinct points. pub(crate) fn collect_face_polygon( sat: &SatDocument, face: &SatFace, - circ_segs: usize, + chord_frac: f64, ) -> Vec<[f64; 3]> { let Some(loop_rec) = sat.resolve(face.first_loop()) else { return vec![]; @@ -1048,14 +1103,14 @@ pub(crate) fn collect_face_polygon( let Some(sat_loop) = SatLoop::from_record(loop_rec) else { return vec![]; }; - collect_loop_polygon(sat, &sat_loop, circ_segs) + collect_loop_polygon(sat, &sat_loop, chord_frac) } /// Boundary points of a single coedge loop, in order. pub(crate) fn collect_loop_polygon( sat: &SatDocument, sat_loop: &SatLoop, - circ_segs: usize, + chord_frac: f64, ) -> Vec<[f64; 3]> { let first_ptr = sat_loop.first_coedge(); let mut cur = first_ptr; @@ -1070,7 +1125,7 @@ pub(crate) fn collect_loop_polygon( if let Some(ce_rec) = sat.resolve(cur) { if let Some(coedge) = SatCoedge::from_record(ce_rec) { - append_coedge_points(sat, &coedge, circ_segs, &mut pts); + append_coedge_points(sat, &coedge, chord_frac, &mut pts); let next = coedge.next(); if next == first_ptr { break; @@ -1090,7 +1145,7 @@ pub(crate) fn collect_loop_polygon( pub(crate) fn collect_face_loops( sat: &SatDocument, face: &SatFace, - circ_segs: usize, + chord_frac: f64, ) -> Vec> { let mut loops: Vec> = Vec::new(); let mut loop_ptr = face.first_loop(); @@ -1102,7 +1157,7 @@ pub(crate) fn collect_face_loops( let Some(sat_loop) = SatLoop::from_record(loop_rec) else { break; }; - let poly = collect_loop_polygon(sat, &sat_loop, circ_segs); + let poly = collect_loop_polygon(sat, &sat_loop, chord_frac); if poly.len() >= 3 { loops.push(poly); } @@ -1118,7 +1173,7 @@ pub(crate) fn collect_face_loops( pub(crate) fn append_coedge_points( sat: &SatDocument, coedge: &SatCoedge, - circ_segs: usize, + chord_frac: f64, pts: &mut Vec<[f64; 3]>, ) { let fwd = matches!(coedge.sense(), Sense::Forward); @@ -1138,7 +1193,7 @@ pub(crate) fn append_coedge_points( &ellipse, edge.start_param(), edge.end_param(), - circ_segs, + chord_frac, reversed, ); if !sampled.is_empty() { @@ -1154,7 +1209,11 @@ pub(crate) fn append_coedge_points( // this the face's parametric extent collapses and it can't be trimmed. if let Some(ic) = SatIntCurve::from_record(curve_rec) { let mut sampled: Vec<[f64; 3]> = ic - .sample_range(edge.start_param(), edge.end_param(), circ_segs) + .sample_range( + edge.start_param(), + edge.end_param(), + nominal_segs(chord_frac), + ) .into_iter() .map(|(x, y, z)| [x, y, z]) .collect(); @@ -1189,7 +1248,7 @@ fn sample_ellipse_arc( ellipse: &SatEllipseCurve, sp: f64, ep: f64, - circ_segs: usize, + chord_frac: f64, curve_reversed: bool, ) -> Vec<[f64; 3]> { let span = ep - sp; @@ -1220,7 +1279,16 @@ fn sample_ellipse_arc( // cylinder of the surface radius. let hand = if curve_reversed { -1.0 } else { 1.0 }; - let segs = (circ_segs as f64 * (span.abs() / TAU)).round().max(2.0) as usize; + // Segment count from this ellipse's own radius and arc span at the requested + // chord tolerance — the same model the 2-D circle/arc/ellipse wires use, so + // a big rim samples finer than a small one and a short arc proportionally + // less than a full turn. `major_len` is the reference radius. + let segs = crate::scene::convert::tess_util::arc_segments_floored( + major_len, + span.abs(), + major_len * chord_frac, + 2, + ) as usize; let mut out = Vec::with_capacity(segs); for i in 0..segs { let t = sp + span * (i as f64 / segs as f64); @@ -1271,12 +1339,12 @@ pub(crate) fn tess_plane_face( sat: &SatDocument, face: &SatFace, plane: &SatPlaneSurface, - circ_segs: usize, + chord_frac: f64, verts: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { - let mut poly = collect_face_polygon(sat, face, circ_segs); + let mut poly = collect_face_polygon(sat, face, chord_frac); if poly.len() < 3 { return; } @@ -1320,7 +1388,7 @@ pub(crate) fn tess_cone_face( indices: &mut Vec, ) { // Determine the height range and angular span from the boundary polygon. - let poly = collect_face_polygon(sat, face, lod.circ_segs); + let poly = collect_face_polygon(sat, face, lod.chord_frac); let (cx, cy, cz) = cone.center(); let (ax, ay, az) = cone.axis(); // axis direction (unit) @@ -1367,11 +1435,20 @@ pub(crate) fn tess_cone_face( return; } - // Angular divisions scale with the arc's share of a full circle, so a short - // boundary arc (a curved wall face) isn't tessellated as finely as a whole - // rim — matching the wire arc sampler and keeping the triangle budget sane. - let segs_u = ((lod.circ_segs as f64 * theta_span / TAU).round() as usize).max(1); - let segs_v = (lod.circ_segs / 4).max(1); // height subdivisions + // Angular divisions from the rim radius and arc span at the LOD's chord + // tolerance — a short boundary arc (a curved wall face) samples proportionally + // less than a whole rim. Use the wider rim so the density bounds chord error + // at both ends. The height direction is a straight generator (a cone/cylinder + // is ruled), so it carries no curvature: one division is geometrically exact. + let r_ref = if cos_a.abs() > 1e-9 { + (radius + h_min * sin_a / cos_a) + .abs() + .max((radius + h_max * sin_a / cos_a).abs()) + } else { + radius.abs() + }; + let segs_u = lod.arc_segs(r_ref, theta_span).max(1); + let segs_v = 1; // straight generator — no curvature along the height for j in 0..segs_v { let t0 = h_min + h_span * (j as f64 / segs_v as f64); @@ -1593,13 +1670,10 @@ pub(crate) fn tess_sphere_face( let u_dir = norm3([ux, uy, uz]); let v_dir = cross3(pole, u_dir); - let nu = lod.grid_u.max(3); - let nv = lod.grid_v.max(2); - // Mesh only the part of the sphere the face covers — its boundary loop's // longitude/colatitude window. A partial sphere (a fillet cap) otherwise // builds as a full ball floating where the solid is open. - let poly = collect_face_polygon(sat, face, nu.max(24)); + let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); let (t_min, t_span, full, p_min, p_max) = sphere_param_range(&poly, [cx, cy, cz], pole, u_dir, v_dir); let (theta_lo, theta_hi) = if full { @@ -1613,6 +1687,12 @@ pub(crate) fn tess_sphere_face( (p_min, p_max) }; + // Longitude / colatitude divisions from the sphere radius and the covered + // spans at the LOD's chord tolerance — a small cap samples far fewer than a + // full ball. Every circle of latitude is at most radius `r` (the equator). + let nu = lod.arc_segs(r, theta_hi - theta_lo).max(1); + let nv = lod.arc_segs(r, phi_hi - phi_lo).max(1); + for j in 0..nv { let phi0 = phi_lo + (phi_hi - phi_lo) * (j as f64 / nv as f64); let phi1 = phi_lo + (phi_hi - phi_lo) * ((j + 1) as f64 / nv as f64); @@ -1679,14 +1759,17 @@ pub(crate) fn tess_torus_face( let major_r = torus.major_radius(); let minor_r = torus.minor_radius(); - let nu = lod.grid_u.max(3); // around the tube + // Tube cross-section is a closed minor circle → full-circle segment count at + // the tube (minor) radius. + let nu = lod.circle_segs(minor_r).max(3); // around the tube // Mesh only the revolution arc the face covers. A partial tube (an open "C") // otherwise builds as a full closed ring where the solid is open. let (phi_start, phi_arc, full) = torus_phi_range(sat, face, [cx, cy, cz], u_dir, v_dir); let phi_total = if full { TAU } else { phi_arc }; - // Keep the along-length facet density independent of the arc length. - let nv = (((phi_total / TAU) * lod.grid_v as f64).ceil() as usize).max(3); + // Along-length divisions from the ring (major) radius and the covered arc at + // the LOD's chord tolerance — a short arc samples proportionally less. + let nv = lod.arc_segs(major_r, phi_total).max(2); for j in 0..nv { let phi0 = phi_start + phi_total * (j as f64 / nv as f64); diff --git a/src/scene/convert/spline_tess.rs b/src/scene/convert/spline_tess.rs index 260a86b5b..f39379bd3 100644 --- a/src/scene/convert/spline_tess.rs +++ b/src/scene/convert/spline_tess.rs @@ -77,8 +77,11 @@ pub fn tess_spline_face( return; } - let su = lod.grid_u.max(8); - let sv = lod.grid_v.max(8); + // A B-spline patch has no single analytic radius to drive a chord-tolerance + // count, so sample at the LOD's nominal density (its unit-circle segment + // count). Floor 8 so a curved patch stays smooth. + let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8); + let (su, sv) = (n, n); let base = verts.len() as u32; for j in 0..=sv { diff --git a/src/scene/convert/tess_util.rs b/src/scene/convert/tess_util.rs index 3ada053ff..01ca0c4af 100644 --- a/src/scene/convert/tess_util.rs +++ b/src/scene/convert/tess_util.rs @@ -47,7 +47,11 @@ pub type FallbackGeometry = ( /// 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) }; + let (sa, ea) = if ccw { + (start, end) + } else { + (TAU - start, TAU - end) + }; (sa, ea - sa) } @@ -63,13 +67,21 @@ pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) { /// 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 1; + 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(8, 512) + ((span_abs / max_step).ceil() as u32).clamp(floor, 512) } /// Chord tolerance for the load-time fill polygon: 0.1% of radius,