refactor(solid3d): drive curved-face sampling from one chord tolerance

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-18 01:31:46 +03:00
1 parent 570165e77f
commit 608cf8c74b
4 files changed
+185 -92

No files matched your search

+7 -12
View File
@@ -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<Face> {
// 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();
+158 -75
View File
@@ -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<ConeFaceGeom> {
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<Vec<[f64; 3]>> {
let mut loops: Vec<Vec<[f64; 3]>> = 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<u32>,
) {
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<u32>,
) {
// 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);
+5 -2
View File
@@ -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 {
+15 -3
View File
@@ -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,