fix(acis): render complex solid surfaces

Scope revolution spans to face boundaries and preserve pierced cylinders and rational spline lofts so valid bodies no longer produce stretched or missing shells.
This commit is contained in:
Hakan Seven committed 2026-08-03 19:03:59 +03:00
1 parent de2707a23e
commit f18ba87858
5 files changed
+171 -57

No files matched your search

Generated
+1 -1
View File
@@ -85,7 +85,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6f9a92f#6f9a92f665b88c29970b7efd658ecf30dc589750"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=ab388a7#ab388a7de9b38f7b06e963c35b45fb35d9fee97a"
dependencies = [
"ahash 0.8.12",
"anyhow",
+1 -1
View File
@@ -62,7 +62,7 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
[patch.crates-io]
acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6f9a92f" }
acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "ab388a7" }
iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
+60 -35
View File
@@ -6,7 +6,8 @@
// instead of the bespoke per-surface sampler in `solid3d_tess`.
//
// plane-surface → planar face from the sampled boundary loop
// cone-surface → surface of revolution (cylinder / cone) via rsweep
// cone-surface → partial revolutions via rsweep; closed revolutions use
// the parametric sampler
// sphere-surface → bespoke sampler, clipped to the boundary window (truck
// builds a full ball and cannot trim it to a partial face)
// torus-surface → bespoke sampler, clipped to the revolution arc between
@@ -301,26 +302,6 @@ fn plane_face(sat: &SatDocument, face: &SatFace) -> Option<Face> {
if loops.is_empty() {
return None;
}
let build_wire = |pts: &[[f64; 3]], reverse: bool| -> Option<Wire> {
if pts.len() < 3 {
return None;
}
let verts: Vec<_> = if reverse {
pts.iter()
.rev()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect::<Vec<_>>()
} else {
pts.iter()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect::<Vec<_>>()
};
let n = verts.len();
let edges: Vec<_> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
Some(edges.into())
};
// The outer boundary is the loop that encloses the rest — i.e. the one with
// the largest area. ACIS does not guarantee it comes first in the face's
// loop list (a pierced wall lists its window holes before the wall edge),
@@ -343,19 +324,40 @@ fn plane_face(sat: &SatDocument, face: &SatFace) -> Option<Face> {
// test robust against sampling noise on small holes. (#123)
let outer_n = loop_normal(&loops[outer_idx]);
let mut wires: Vec<Wire> = Vec::new();
wires.push(build_wire(&loops[outer_idx], false)?);
wires.push(polygon_wire(&loops[outer_idx], false)?);
for (i, lp) in loops.iter().enumerate() {
if i == outer_idx {
continue;
}
let same = vdot(loop_normal(lp), outer_n) > 0.0;
if let Some(w) = build_wire(lp, same) {
if let Some(w) = polygon_wire(lp, same) {
wires.push(w);
}
}
builder::try_attach_plane(&wires).ok()
}
fn polygon_wire(pts: &[[f64; 3]], reverse: bool) -> Option<Wire> {
if pts.len() < 3 {
return None;
}
let verts: Vec<_> = if reverse {
pts.iter()
.rev()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect()
} else {
pts.iter()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect()
};
let n = verts.len();
let edges: Vec<_> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
Some(edges.into())
}
/// Newell area-weighted normal of a closed 3-D polygon (orientation only).
fn loop_normal(pts: &[[f64; 3]]) -> [f64; 3] {
let mut n = [0.0f64; 3];
@@ -372,11 +374,9 @@ 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);
/// 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.
/// Build a cone/cylinder lateral surface as revolution faces. A plain closed
/// revolution uses the parametric sampler; a pierced one stays here so its
/// additional boundary loops can trim holes into the curved surface.
fn cone_faces(
sat: &SatDocument,
face: &SatFace,
@@ -400,7 +400,8 @@ fn cone_faces(
);
let center = Point3::new(cx, cy, cz);
let (hmin, hmax) = cone_axis_span(sat, cone, [axis.x, axis.y, axis.z], [cx, cy, cz])?;
let (hmin, hmax) =
cone_axis_span(sat, face, cone, [axis.x, axis.y, axis.z], [cx, cy, cz])?;
let r_at = |h: f64| {
if cos.abs() > 1e-9 {
radius + h * sin / cos
@@ -409,10 +410,26 @@ 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_CHORD_FRAC);
let (theta0, sweep) = cone_boundary_arc(&poly, [cx, cy, cz], axis, udir, vdir);
let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC);
let arcs: Vec<(f64, f64)> = loops
.iter()
.map(|lp| cone_boundary_arc(lp, [cx, cy, cz], axis, udir, vdir))
.collect();
let outer_idx = arcs
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)?;
let (theta0, sweep) = arcs[outer_idx];
let closed = sweep >= std::f64::consts::TAU;
let hole_indices: Vec<usize> = arcs
.iter()
.enumerate()
.filter_map(|(i, arc)| (i != outer_idx && arc.1 < std::f64::consts::TAU).then_some(i))
.collect();
if closed && hole_indices.is_empty() {
return None;
}
// Radial direction at the arc's start angle (u rotated by theta0 about axis).
let rad0 = udir * theta0.cos() + vdir * theta0.sin();
@@ -420,6 +437,14 @@ fn cone_faces(
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(sweep));
let mut faces: Vec<Face> = shell.face_iter().cloned().collect();
if closed {
let lateral = faces.first_mut()?;
for index in hole_indices {
let wire = polygon_wire(&loops[index], false)?;
lateral.try_add_boundary(wire).ok()?;
}
}
let out = Outward::Cone {
center: [cx, cy, cz],
@@ -427,7 +452,7 @@ fn cone_faces(
sin,
cos,
};
Some((shell.face_iter().cloned().collect(), out))
Some((faces, out))
}
/// Smallest angular arc `(theta_start, sweep)` about the cone axis enclosing the
@@ -474,7 +499,7 @@ fn cone_boundary_arc(
}
let span = TAU - max_gap;
// Nearly a full revolution → treat as a closed rim.
if span > TAU * 0.98 || span < 1e-6 {
if span >= TAU * 0.98 - 1e-9 || span < 1e-6 {
return (0.0, FULL);
}
// Start at the sample just after the largest gap, sweep CCW by `span`.
+75 -19
View File
@@ -409,7 +409,7 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option<ConeFaceGeom> {
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 {
if let Some((vmin, vmax)) = cone_axis_span(sat, &cone, axis, [cx, cy, cz]) {
if let Some((vmin, vmax)) = cone_axis_span(sat, face, &cone, axis, [cx, cy, cz]) {
h_min = vmin;
h_max = vmax;
if full {
@@ -1652,7 +1652,7 @@ pub(crate) fn tess_cone_face(
// 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]) {
if let Some((vmin, vmax)) = cone_axis_span(sat, face, cone, axis, [cx, cy, cz]) {
h_min = vmin;
h_max = vmax;
if full_circle {
@@ -1835,20 +1835,54 @@ fn angular_range(
/// circular rims or B-rep vertices when the face boundary collapses to a
/// single height.
///
/// Scans every ellipse/circle curve in the document, keeps those coaxial with
/// this cone (centre on the axis line, normal parallel to the axis), and
/// projects their centres onto the axis to get rim heights. Some imported
/// solids represent circular rims as spline/intcurve edges, so point records
/// lying on the analytic cone are the secondary source. For a true cone with a
/// single rim, the tip is added analytically. Returns `None` when no span is
/// recoverable.
/// Uses this face's own boundary loops first. If that topology is incomplete,
/// matching coaxial rims and points on the analytic surface provide a fallback.
/// For a true cone with a single rim, the tip is added analytically. Returns
/// `None` when no span is recoverable.
pub(crate) fn cone_axis_span(
sat: &SatDocument,
face: &SatFace,
cone: &SatConeSurface,
axis: [f64; 3],
center: [f64; 3],
) -> Option<(f64, f64)> {
let mut heights: Vec<f64> = Vec::new();
// Prefer the boundaries owned by this face. A body may contain several
// coaxial cylinders, so document-wide rims cannot identify this face's
// axial extent reliably.
for point in collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC)
.into_iter()
.flatten()
{
heights.push(dot3(
[
point[0] - center[0],
point[1] - center[1],
point[2] - center[2],
],
axis,
));
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap());
heights.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if heights.len() >= 2 {
let h_min = heights[0];
let h_max = *heights.last().unwrap();
if (h_max - h_min).abs() >= 1e-9 {
return Some((h_min, h_max));
}
}
let local_height = heights.first().copied();
heights.clear();
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
let tangent = if cos_a.abs() > 1e-9 {
sin_a / cos_a
} else {
0.0
};
for rec in &sat.records {
if rec.entity_type != "ellipse-curve" {
continue;
@@ -1864,18 +1898,19 @@ pub(crate) fn cone_axis_span(
let radial_len = dot3(radial, radial).sqrt();
let n = e.normal();
let n_dot = dot3(norm3([n.0, n.1, n.2]), axis).abs();
if radial_len < 1e-6 && n_dot > 0.999 {
let major = e.major_axis();
let curve_radius = dot3([major.0, major.1, major.2], [major.0, major.1, major.2])
.sqrt();
let expected_radius = (cone.radius() + h * tangent).abs();
let scale = curve_radius.max(expected_radius).max(1.0);
if radial_len < scale * 1e-6
&& n_dot > 0.999
&& (curve_radius - expected_radius).abs() < scale * 1e-5
{
heights.push(h);
}
}
if heights.len() < 2 {
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
let tangent = if cos_a.abs() > 1e-9 {
sin_a / cos_a
} else {
0.0
};
for rec in &sat.records {
let Some(point) = SatPoint::from_record(rec) else {
continue;
@@ -1906,12 +1941,33 @@ pub(crate) fn cone_axis_span(
heights.sort_by(|a, b| a.partial_cmp(b).unwrap());
heights.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if let Some(anchor) = local_height {
if let Some(mate) = heights
.iter()
.copied()
.filter(|h| (*h - anchor).abs() >= 1e-6)
.min_by(|a, b| {
(a - anchor)
.abs()
.partial_cmp(&(b - anchor).abs())
.unwrap()
})
{
return Some((anchor.min(mate), anchor.max(mate)));
}
if sin_a.abs() > 1e-6 {
let apex = -cone.radius() * cos_a / sin_a;
if (apex - anchor).abs() >= 1e-9 {
return Some((anchor.min(apex), anchor.max(apex)));
}
}
return None;
}
let mut h_min = heights[0];
let mut h_max = *heights.last().unwrap();
// True cone with a single rim: close the surface at its apex (r = 0).
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
if sin_a.abs() > 1e-6 && heights.len() <= 1 {
let apex = -cone.radius() * cos_a / sin_a;
h_min = h_min.min(apex);
+34 -1
View File
@@ -9,7 +9,7 @@
use acadrust::entities::acis::types::Sense;
use acadrust::entities::acis::{
SatCoedge, SatDocument, SatFace, SatLoop, SatPCurve, SatRecord, SatToken,
SatCoedge, SatDocument, SatFace, SatLoop, SatPCurve, SatRecord, SatSplineSurface, SatToken,
};
use rustc_hash::FxHashSet;
use truck_modeling::base::{Vector3, Vector4};
@@ -287,6 +287,10 @@ fn range_bounds(r: (std::ops::Bound<f64>, std::ops::Bound<f64>)) -> (f64, f64) {
/// Parse the `nubs` control net + knot vectors out of a `spline-surface`
/// record's token stream into a truck `BSplineSurface`.
fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<SplineSurf> {
if let Some(surface) = build_decoded_spline_surface(sat, rec) {
return Some(surface);
}
let mut toks = rec.tokens.as_slice();
if let Some(reference) = primary_subtype_reference(toks) {
toks = sat.subtype_tokens(reference)?;
@@ -417,6 +421,35 @@ fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<SplineSurf
}
}
fn build_decoded_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<SplineSurf> {
let spline = SatSplineSurface::from_record(rec)?;
let decoded = spline.bspline(sat)?;
let uk = KnotVec::from(decoded.u_knots);
let vk = KnotVec::from(decoded.v_knots);
let mut ctrl = vec![Vec::with_capacity(decoded.control_count_v); decoded.control_count_u];
if decoded.rational {
for v in 0..decoded.control_count_v {
for u in 0..decoded.control_count_u {
let point = decoded.control_points[v * decoded.control_count_u + u];
ctrl[u].push(Vector4::new(point[0], point[1], point[2], point[3]));
}
}
let surface = BSplineSurface::try_new((uk, vk), ctrl).ok()?;
Some(SplineSurf::Nurbs(NurbsSurface::new(surface)))
} else {
let mut points = vec![Vec::with_capacity(decoded.control_count_v); decoded.control_count_u];
for v in 0..decoded.control_count_v {
for u in 0..decoded.control_count_u {
let point = decoded.control_points[v * decoded.control_count_u + u];
points[u].push(Point3::new(point[0], point[1], point[2]));
}
}
let surface = BSplineSurface::try_new((uk, vk), points).ok()?;
Some(SplineSurf::Bs(surface))
}
}
/// Read `count` `(knot value, multiplicity)` pairs into an expanded raw knot
/// vector. Some ACIS families store degree-sized ends and others already carry
/// the complete knot vector; the control-net size decides that after both axes