fix(solid3d): curved-wall cone faces — balloon, seam, and gaps
Three defects in ACIS curved-wall (cone-surface) tessellation, all seen in a Villa Savoye model: - Balloon: sample_ellipse_arc ignored the edge's own sense relative to its curve (distinct from the coedge/loop sense). A reversed edge winds the ellipse about the opposite normal, so evaluating P(t) with the stored normal mirrors a short boundary arc across the major axis to the far side of the circle. That poisoned the face's angular-span estimate and swept the whole 2pi revolution, drawing a curved wall as a full cylinder of the surface radius. Honour edge.sense() (negate the sin term when reversed) so the arc traces its true vertices. - Seam: the fallback sampler's angular_range used raw min/max of atan2 angles, so an arc straddling the +/-pi seam read as a 2pi span (false full circle). Derive the arc from the largest angular gap instead. - Gaps: truck's rsweep/triangulation degenerates to zero triangles on some short-arc cylinders (profile through the local origin), leaving a see-through hole while the rest of the solid renders. Fall back to the bespoke parametric sampler for just that face, into the same body-local buffers. Also scale tess_cone_face's angular divisions by the arc span so a short arc isn't tessellated as densely as a full rim. Verified against the model: ballooned walls become wall-sized, and a sweep of all 528 solids shows no empty faces and no triangle explosion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
714fed7f00
commit
76d2aca10c
2 changed files with 117 additions and 24 deletions
|
|
@ -28,7 +28,8 @@ use acadrust::entities::acis::{
|
||||||
|
|
||||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||||
use crate::scene::convert::solid3d_tess::{
|
use crate::scene::convert::solid3d_tess::{
|
||||||
apply_body_transform, body_transform, collect_face_loops, cone_axis_span,
|
apply_body_transform, body_transform, collect_face_loops, cone_axis_span, tess_cone_face,
|
||||||
|
tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Slightly over 2π so revolution builders close the loop.
|
/// Slightly over 2π so revolution builders close the loop.
|
||||||
|
|
@ -123,19 +124,30 @@ pub fn tessellate_sat_truck(
|
||||||
selected: false,
|
selected: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for face in sat.faces() {
|
for face in sat.faces().into_iter() {
|
||||||
let Some(surf_rec) = sat.resolve(face.surface()) else {
|
let Some(surf_rec) = sat.resolve(face.surface()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) else {
|
let mut appended = false;
|
||||||
continue;
|
if let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) {
|
||||||
};
|
if !faces.is_empty() {
|
||||||
if faces.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let shell: Shell = faces.into();
|
let shell: Shell = faces.into();
|
||||||
let poly = shell.triangulation(tol).to_polygon();
|
let poly = shell.triangulation(tol).to_polygon();
|
||||||
|
if !poly.tri_faces().is_empty() {
|
||||||
append_group(&mut mesh, &poly, &outward);
|
append_group(&mut mesh, &poly, &outward);
|
||||||
|
appended = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Truck's rsweep/triangulation degenerates to zero triangles on some
|
||||||
|
// short-arc cones (a curved wall face whose profile passes through the
|
||||||
|
// local origin), leaving a see-through gap. Fall back to the bespoke
|
||||||
|
// parametric sampler for just that face — it writes body-local verts
|
||||||
|
// into the same buffers, so the shared body transform below still
|
||||||
|
// applies uniformly.
|
||||||
|
if !appended {
|
||||||
|
bespoke_face(sat, &face, surf_rec, &mut mesh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spline (NURBS) faces are meshed by direct grid sampling of the truck
|
// Spline (NURBS) faces are meshed by direct grid sampling of the truck
|
||||||
|
|
@ -152,6 +164,41 @@ pub fn tessellate_sat_truck(
|
||||||
Some(MeshLodSet::from_lods(vec![mesh]))
|
Some(MeshLodSet::from_lods(vec![mesh]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill one face with the bespoke parametric sampler (body-local verts into the
|
||||||
|
/// shared mesh buffers) when truck produced no triangles for it. Mirrors the
|
||||||
|
/// surface dispatch of the standalone sampler.
|
||||||
|
fn bespoke_face(
|
||||||
|
sat: &SatDocument,
|
||||||
|
face: &SatFace,
|
||||||
|
surf_rec: &acadrust::entities::acis::SatRecord,
|
||||||
|
mesh: &mut MeshModel,
|
||||||
|
) {
|
||||||
|
let (v, n, i) = (&mut mesh.verts, &mut mesh.normals, &mut mesh.indices);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cone-surface" => {
|
||||||
|
if let Some(c) = SatConeSurface::from_record(surf_rec) {
|
||||||
|
tess_cone_face(sat, face, &c, LodConfig::HIGH, v, n, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sphere-surface" => {
|
||||||
|
if let Some(s) = SatSphereSurface::from_record(surf_rec) {
|
||||||
|
tess_sphere_face(&s, LodConfig::HIGH, v, n, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"torus-surface" => {
|
||||||
|
if let Some(t) = SatTorusSurface::from_record(surf_rec) {
|
||||||
|
tess_torus_face(&t, LodConfig::HIGH, v, n, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the truck face(s) + outward rule for one analytic ACIS face.
|
/// Build the truck face(s) + outward rule for one analytic ACIS face.
|
||||||
fn build_face_group(
|
fn build_face_group(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,14 @@ fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> {
|
||||||
let mut pts: Vec<[f64; 3]> = Vec::new();
|
let mut pts: Vec<[f64; 3]> = Vec::new();
|
||||||
if let Some(cr) = sat.resolve(edge.curve()) {
|
if let Some(cr) = sat.resolve(edge.curve()) {
|
||||||
if let Some(ellipse) = SatEllipseCurve::from_record(cr) {
|
if let Some(ellipse) = SatEllipseCurve::from_record(cr) {
|
||||||
pts = sample_ellipse_arc(&ellipse, edge.start_param(), edge.end_param(), EDGE_SEGS);
|
let reversed = matches!(edge.sense(), Sense::Reversed);
|
||||||
|
pts = sample_ellipse_arc(
|
||||||
|
&ellipse,
|
||||||
|
edge.start_param(),
|
||||||
|
edge.end_param(),
|
||||||
|
EDGE_SEGS,
|
||||||
|
reversed,
|
||||||
|
);
|
||||||
// sample_ellipse_arc drops the end param; append the true end so
|
// sample_ellipse_arc drops the end param; append the true end so
|
||||||
// the polyline closes onto the shared vertex.
|
// the polyline closes onto the shared vertex.
|
||||||
if let Some(p) = vertex_point(sat, edge.end_vertex()) {
|
if let Some(p) = vertex_point(sat, edge.end_vertex()) {
|
||||||
|
|
@ -512,11 +519,15 @@ pub(crate) fn append_coedge_points(
|
||||||
|
|
||||||
if let Some(curve_rec) = sat.resolve(edge.curve()) {
|
if let Some(curve_rec) = sat.resolve(edge.curve()) {
|
||||||
if let Some(ellipse) = SatEllipseCurve::from_record(curve_rec) {
|
if let Some(ellipse) = SatEllipseCurve::from_record(curve_rec) {
|
||||||
|
// The edge's own sense (relative to its curve) decides the ellipse
|
||||||
|
// winding; a reversed edge samples the opposite handedness.
|
||||||
|
let reversed = matches!(edge.sense(), Sense::Reversed);
|
||||||
let mut sampled = sample_ellipse_arc(
|
let mut sampled = sample_ellipse_arc(
|
||||||
&ellipse,
|
&ellipse,
|
||||||
edge.start_param(),
|
edge.start_param(),
|
||||||
edge.end_param(),
|
edge.end_param(),
|
||||||
circ_segs,
|
circ_segs,
|
||||||
|
reversed,
|
||||||
);
|
);
|
||||||
if !sampled.is_empty() {
|
if !sampled.is_empty() {
|
||||||
if !fwd {
|
if !fwd {
|
||||||
|
|
@ -548,6 +559,7 @@ fn sample_ellipse_arc(
|
||||||
sp: f64,
|
sp: f64,
|
||||||
ep: f64,
|
ep: f64,
|
||||||
circ_segs: usize,
|
circ_segs: usize,
|
||||||
|
curve_reversed: bool,
|
||||||
) -> Vec<[f64; 3]> {
|
) -> Vec<[f64; 3]> {
|
||||||
let span = ep - sp;
|
let span = ep - sp;
|
||||||
if span.abs() < 1e-9 {
|
if span.abs() < 1e-9 {
|
||||||
|
|
@ -568,11 +580,20 @@ fn sample_ellipse_arc(
|
||||||
let minor_u = cross3(normal, major_u);
|
let minor_u = cross3(normal, major_u);
|
||||||
let minor_len = major_len * ellipse.ratio();
|
let minor_len = major_len * ellipse.ratio();
|
||||||
|
|
||||||
|
// P(t) = center + major·cos t + (normal×major)·ratio·sin t, with param
|
||||||
|
// winding CCW about the curve normal. An edge stored with REVERSED sense
|
||||||
|
// traverses the curve backward, i.e. about the opposite normal, so its
|
||||||
|
// params index the mirror winding — evaluate with the sin term negated.
|
||||||
|
// Ignoring the edge sense mirrors a reversed boundary arc across the major
|
||||||
|
// axis to the far side of the circle, ballooning a curved wall into a full
|
||||||
|
// 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;
|
let segs = (circ_segs as f64 * (span.abs() / TAU)).round().max(2.0) as usize;
|
||||||
let mut out = Vec::with_capacity(segs);
|
let mut out = Vec::with_capacity(segs);
|
||||||
for i in 0..segs {
|
for i in 0..segs {
|
||||||
let t = sp + span * (i as f64 / segs as f64);
|
let t = sp + span * (i as f64 / segs as f64);
|
||||||
let (c, s) = (t.cos(), t.sin());
|
let (c, s) = (t.cos(), t.sin() * hand);
|
||||||
out.push([
|
out.push([
|
||||||
center.0 + major_u[0] * major_len * c + minor_u[0] * minor_len * s,
|
center.0 + major_u[0] * major_len * c + minor_u[0] * minor_len * s,
|
||||||
center.1 + major_u[1] * major_len * c + minor_u[1] * minor_len * s,
|
center.1 + major_u[1] * major_len * c + minor_u[1] * minor_len * s,
|
||||||
|
|
@ -615,7 +636,7 @@ fn push_quad(
|
||||||
|
|
||||||
// ── Planar face ───────────────────────────────────────────────────────────────
|
// ── Planar face ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn tess_plane_face(
|
pub(crate) fn tess_plane_face(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
face: &SatFace,
|
face: &SatFace,
|
||||||
plane: &SatPlaneSurface,
|
plane: &SatPlaneSurface,
|
||||||
|
|
@ -658,7 +679,7 @@ fn tess_plane_face(
|
||||||
|
|
||||||
// ── Cone / cylinder face ──────────────────────────────────────────────────────
|
// ── Cone / cylinder face ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn tess_cone_face(
|
pub(crate) fn tess_cone_face(
|
||||||
sat: &SatDocument,
|
sat: &SatDocument,
|
||||||
face: &SatFace,
|
face: &SatFace,
|
||||||
cone: &SatConeSurface,
|
cone: &SatConeSurface,
|
||||||
|
|
@ -704,9 +725,6 @@ fn tess_cone_face(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let segs_u = lod.circ_segs;
|
|
||||||
let segs_v = (segs_u / 4).max(1); // height subdivisions
|
|
||||||
|
|
||||||
let theta_span = if full_circle {
|
let theta_span = if full_circle {
|
||||||
TAU
|
TAU
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -718,6 +736,12 @@ fn tess_cone_face(
|
||||||
return;
|
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
|
||||||
|
|
||||||
for j in 0..segs_v {
|
for j in 0..segs_v {
|
||||||
let t0 = h_min + h_span * (j as f64 / segs_v as f64);
|
let t0 = h_min + h_span * (j as f64 / segs_v as f64);
|
||||||
let t1 = h_min + h_span * ((j + 1) as f64 / segs_v as f64);
|
let t1 = h_min + h_span * ((j + 1) as f64 / segs_v as f64);
|
||||||
|
|
@ -824,13 +848,35 @@ fn angular_range(
|
||||||
angles.push(rv.atan2(ru));
|
angles.push(rv.atan2(ru));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalise angles to a contiguous range.
|
// Find the arc from the LARGEST angular gap, not raw min/max. `atan2`
|
||||||
|
// returns [-π, π]; a short arc that straddles the ±π seam splits its points
|
||||||
|
// between ≈+π and ≈-π, so naive `max - min` reads ≈2π and the face balloons
|
||||||
|
// into a full revolution (a curved wall of radius R drawn as a whole
|
||||||
|
// R-cylinder). The empty region a face doesn't cover is its largest gap, so
|
||||||
|
// the real arc is the complement: it starts just after the gap and runs to
|
||||||
|
// just before it (wrapping past the seam when needed).
|
||||||
angles.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
angles.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
let theta_min = *angles.first().unwrap();
|
let n = angles.len();
|
||||||
let theta_max = *angles.last().unwrap();
|
let (mut max_gap, mut gap_i) = (0.0_f64, 0_usize);
|
||||||
|
for i in 0..n {
|
||||||
|
let a = angles[i];
|
||||||
|
let b = if i + 1 < n { angles[i + 1] } else { angles[0] + TAU };
|
||||||
|
let gap = b - a;
|
||||||
|
if gap > max_gap {
|
||||||
|
max_gap = gap;
|
||||||
|
gap_i = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the angular span is almost 2π, treat as full circle.
|
// A genuine full circle has points spread all the way round, so its largest
|
||||||
let full = (theta_max - theta_min) > TAU * 0.95;
|
// gap is small. A real arc leaves a wide empty wedge.
|
||||||
|
let full = max_gap < TAU * 0.05;
|
||||||
|
|
||||||
|
let theta_min = angles[(gap_i + 1) % n];
|
||||||
|
let mut theta_max = angles[gap_i];
|
||||||
|
if theta_max <= theta_min {
|
||||||
|
theta_max += TAU;
|
||||||
|
}
|
||||||
|
|
||||||
(h_min, h_max, theta_min, theta_max, full)
|
(h_min, h_max, theta_min, theta_max, full)
|
||||||
}
|
}
|
||||||
|
|
@ -899,7 +945,7 @@ pub(crate) fn cone_axis_span(
|
||||||
|
|
||||||
// ── Sphere face ───────────────────────────────────────────────────────────────
|
// ── Sphere face ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn tess_sphere_face(
|
pub(crate) fn tess_sphere_face(
|
||||||
sphere: &SatSphereSurface,
|
sphere: &SatSphereSurface,
|
||||||
lod: LodConfig,
|
lod: LodConfig,
|
||||||
verts: &mut Vec<[f32; 3]>,
|
verts: &mut Vec<[f32; 3]>,
|
||||||
|
|
@ -965,7 +1011,7 @@ fn sphere_dir(pole: [f64; 3], u_dir: [f64; 3], v_dir: [f64; 3], theta: f64, phi:
|
||||||
|
|
||||||
// ── Torus face ────────────────────────────────────────────────────────────────
|
// ── Torus face ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn tess_torus_face(
|
pub(crate) fn tess_torus_face(
|
||||||
torus: &SatTorusSurface,
|
torus: &SatTorusSurface,
|
||||||
lod: LodConfig,
|
lod: LodConfig,
|
||||||
verts: &mut Vec<[f32; 3]>,
|
verts: &mut Vec<[f32; 3]>,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue