forked from 1b-agent/opencadstudio-upstream
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:
1 parent
a8c368aef9
commit
9f7fdc5f8f
2 files changed
+86
-10
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user