fix(solid): tessellate cone-surface faces to their arc, not a full circle

Curved 3D-solid faces backed by an ACIS cone-surface (e.g. the arched
mullion bars of a storefront curtain wall) were swept a full revolution
regardless of the face's actual angular extent, so a bar bounded to an
8-16 degree arc rendered as a full circle of the surface radius — a large
jagged loop instead of a small arch.

cone_faces (the truck B-rep path) now derives the angular span from the
face's boundary loop and rsweeps only that arc; the bespoke fallback
tess_cone_face no longer forces a full revolution when a thin face
collapses to a single height. Arc extraction uses a seam-robust
largest-gap method so faces straddling +/-pi (the crown of an arch) are
not mistaken for full circles.

Verified against Clinic_Architectural.dwg: the seven arch-bar solids drop
from a ~12.9 (2R) bbox to their true ~1-2 unit extent, and a file-wide
check of 155 cone-bearing solids shows none ballooned and none collapsed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-04 16:11:24 +03:00
1 parent a8c368aef9
commit 9f7fdc5f8f
2 files changed
+86 -10

No files matched your search

+78 -6
View File
@@ -177,7 +177,7 @@ fn build_face_group(
"cone-surface" => {
let cone = SatConeSurface::from_record(surf_rec)?;
let tol = curve_tol(cone.radius());
let (faces, out) = cone_faces(sat, &cone)?;
let (faces, out) = cone_faces(sat, face, &cone)?;
Some((faces, out, tol))
}
"sphere-surface" => {
@@ -295,8 +295,15 @@ fn loop_normal(pts: &[[f64; 3]]) -> [f64; 3] {
// ── Cone / cylinder face ─────────────────────────────────────────────────────
/// Build the lateral surface of a cone/cylinder as revolution faces. The
/// height span comes from the solid's coaxial rims (plus a true cone's apex).
fn cone_faces(sat: &SatDocument, cone: &SatConeSurface) -> Option<(Vec<Face>, Outward)> {
/// height span comes from the solid's coaxial rims (plus a true cone's apex);
/// the angular span comes from the face's own boundary loop, so a partial
/// (arc) face — e.g. a curved mullion bar — sweeps only its arc instead of
/// ballooning into a full circle of the surface radius.
fn cone_faces(
sat: &SatDocument,
face: &SatFace,
cone: &SatConeSurface,
) -> Option<(Vec<Face>, Outward)> {
let (cx, cy, cz) = cone.center();
let (ax, ay, az) = cone.axis();
let (ux, uy, uz) = cone.major_axis();
@@ -306,6 +313,13 @@ fn cone_faces(sat: &SatDocument, cone: &SatConeSurface) -> Option<(Vec<Face>, Ou
let axis = norm(Vector3::new(ax, ay, az));
let udir = norm(Vector3::new(ux, uy, uz));
// v = axis × u completes the right-handed frame; angles increase from u
// toward v (CCW about the axis), matching `builder::rsweep`.
let vdir = Vector3::new(
axis.y * udir.z - axis.z * udir.y,
axis.z * udir.x - axis.x * udir.z,
axis.x * udir.y - axis.y * udir.x,
);
let center = Point3::new(cx, cy, cz);
let (hmin, hmax) = cone_axis_span(sat, cone, [axis.x, axis.y, axis.z], [cx, cy, cz])?;
@@ -317,10 +331,16 @@ fn cone_faces(sat: &SatDocument, cone: &SatConeSurface) -> Option<(Vec<Face>, Ou
}
};
let p0 = center + udir * r_at(hmin) + axis * hmin;
let p1 = center + udir * r_at(hmax) + axis * hmax;
// 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 (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();
let p0 = center + rad0 * r_at(hmin) + axis * hmin;
let p1 = center + rad0 * r_at(hmax) + axis * hmax;
let profile = builder::line(&builder::vertex(p0), &builder::vertex(p1));
let shell: Shell = builder::rsweep(&profile, center, axis, Rad(FULL));
let shell: Shell = builder::rsweep(&profile, center, axis, Rad(sweep));
let out = Outward::Cone {
center: [cx, cy, cz],
@@ -331,6 +351,58 @@ fn cone_faces(sat: &SatDocument, cone: &SatConeSurface) -> Option<(Vec<Face>, Ou
Some((shell.face_iter().cloned().collect(), out))
}
/// Smallest angular arc `(theta_start, sweep)` about the cone axis enclosing the
/// face's boundary points, robust to the ±π seam. Returns `(0.0, FULL)` when the
/// points wrap the whole revolution (a closed rim) or the boundary is unusable,
/// so a real cylinder/cone still sweeps a complete (overlapped) circle.
fn cone_boundary_arc(
poly: &[[f64; 3]],
center: [f64; 3],
_axis: Vector3,
udir: Vector3,
vdir: Vector3,
) -> (f64, f64) {
use std::f64::consts::TAU;
let mut angles: Vec<f64> = Vec::new();
for p in poly {
let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let ru = d[0] * udir.x + d[1] * udir.y + d[2] * udir.z;
let rv = d[0] * vdir.x + d[1] * vdir.y + d[2] * vdir.z;
if ru.hypot(rv) > 1e-9 {
angles.push(rv.atan2(ru));
}
}
if angles.len() < 2 {
return (0.0, FULL);
}
angles.sort_by(|a, b| a.partial_cmp(b).unwrap());
// Largest angular gap between consecutive samples (wrapping past the seam);
// the enclosing arc is its complement.
let mut max_gap = 0.0;
let mut gap_at = angles.len() - 1;
for i in 0..angles.len() {
let a = angles[i];
let b = if i + 1 < angles.len() {
angles[i + 1]
} else {
angles[0] + TAU
};
let g = b - a;
if g > max_gap {
max_gap = g;
gap_at = i;
}
}
let span = TAU - max_gap;
// Nearly a full revolution → treat as a closed rim.
if span > TAU * 0.98 || span < 1e-6 {
return (0.0, FULL);
}
// Start at the sample just after the largest gap, sweep CCW by `span`.
let start = angles[(gap_at + 1) % angles.len()];
(start, span)
}
// ── Sphere face ──────────────────────────────────────────────────────────────
fn sphere_faces(sphere: &SatSphereSurface) -> Vec<Face> {
+8 -4
View File
@@ -609,14 +609,17 @@ fn tess_cone_face(
// boundary alone can't span the height — the second extent (top rim or
// apex) lives on a different face. When the boundary collapses to one
// height, recover the span from the solid's coaxial circle rims plus the
// analytic apex of a true cone, and sweep the full revolution.
// analytic apex of a true cone. Only sweep the full revolution when the
// boundary really is a closed rim; a bounded arc face (e.g. a curved
// mullion bar) keeps its own angular span, else it balloons to a circle.
if (h_max - h_min).abs() < 1e-9 {
if let Some((vmin, vmax)) = cone_axis_span(sat, cone, axis, [cx, cy, cz]) {
h_min = vmin;
h_max = vmax;
theta_min = 0.0;
theta_max = TAU;
full_circle = true;
if full_circle {
theta_min = 0.0;
theta_max = TAU;
}
}
}
@@ -1012,3 +1015,4 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
[v[0] / len, v[1] / len, v[2] / len]
}
}