From 570165e77fd09bda0e11523d17d662d45c9352e8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 18 Jul 2026 01:10:29 +0300 Subject: [PATCH] fix(solid3d): trim torus faces to the revolution arc between end caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open "C" tube (the € currency symbol's ring) rendered as a full closed donut: truck builds a torus as a full revolution and can't trim it, and the wireframe/silhouette arc estimate was wrong. The arc estimate failed because `collect_face_polygon` reads only the first loop, and the "widest empty gap = tube body" heuristic breaks once the tube carries interior holes (where the two horizontal bars punch through it): a hole splits the body into wide hole-free stretches that masquerade as the opening. `torus_phi_range` now walks every boundary loop and classifies each: a minor circle sitting in a constant-φ plane (spread < ~8.6°) is an end cap; a wider loop is an interference hole. The opening is the gap flanked on both sides by end caps — a hole never bounds it — so the body is the rest of the turn. Torus faces now route to the bespoke sampler (like spheres); `tess_torus_face` clips its φ grid to that arc, so the shaded tube is an open C. `torus_isolines` and `collect_curved_gens` use the same range for the wireframe rim and the silhouette. Removes the now-dead truck torus path (`torus_faces`, `Outward::Torus`, `vadd`). Co-Authored-By: Claude Opus 4.8 --- src/scene/convert/acis_to_truck.rs | 130 ++--------- src/scene/convert/solid3d_tess.rs | 353 ++++++++++++++++++++++------- src/scene/pipeline/mod.rs | 2 +- 3 files changed, 294 insertions(+), 191 deletions(-) diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index bcd766476..c14939ab5 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -7,8 +7,10 @@ // // plane-surface → planar face from the sampled boundary loop // cone-surface → surface of revolution (cylinder / cone) via rsweep -// sphere-surface → meridian revolved about the pole -// torus-surface → tube circle revolved about the spine +// 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 +// the tube's end caps (truck builds a full closed ring) // spline-surface → truck BSplineSurface, grid-sampled (see spline_tess) // // Each face is meshed independently and its triangles are oriented outward @@ -26,11 +28,11 @@ use acadrust::entities::acis::{ SatConeSurface, SatDocument, SatFace, SatPlaneSurface, SatSphereSurface, SatTorusSurface, }; -use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; 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, }; +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; @@ -58,14 +60,6 @@ enum Outward { sin: f64, cos: f64, }, - /// Sphere: radial from the centre. - Sphere { center: [f64; 3] }, - /// Torus: from the nearest point on the spine circle. - Torus { - center: [f64; 3], - axis: [f64; 3], - major: f64, - }, } impl Outward { @@ -83,23 +77,6 @@ impl Outward { let radial = vnorm(vsub(d, vscale(*axis, h))); vnorm(vsub(vscale(radial, *cos), vscale(*axis, *sin))) } - Outward::Sphere { center } => vnorm(vsub(p, *center)), - Outward::Torus { - center, - axis, - major, - } => { - let d = vsub(p, *center); - let h = vdot(d, *axis); - let radial = vsub(d, vscale(*axis, h)); - let rl = vlen(radial); - let spine = if rl > 1e-9 { - vadd(*center, vscale(radial, major / rl)) - } else { - *center - }; - vnorm(vsub(p, spine)) - } } } } @@ -187,12 +164,12 @@ fn bespoke_face( } "sphere-surface" => { if let Some(s) = SatSphereSurface::from_record(surf_rec) { - tess_sphere_face(&s, LodConfig::HIGH, v, n, i); + tess_sphere_face(sat, 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); + tess_torus_face(sat, face, &t, LodConfig::HIGH, v, n, i); } } _ => {} @@ -227,30 +204,17 @@ fn build_face_group( let (faces, out) = cone_faces(sat, face, &cone)?; Some((faces, out, tol)) } - "sphere-surface" => { - let sphere = SatSphereSurface::from_record(surf_rec)?; - let (cx, cy, cz) = sphere.center(); - let tol = curve_tol(sphere.radius()); - Some(( - sphere_faces(&sphere), - Outward::Sphere { center: [cx, cy, cz] }, - tol, - )) - } - "torus-surface" => { - let torus = SatTorusSurface::from_record(surf_rec)?; - let (cx, cy, cz) = torus.center(); - let (nx, ny, nz) = torus.normal(); - // Tube (minor) curvature is the tighter of the two, so sampling it - // to the circle tolerance keeps the whole torus at least that smooth. - let tol = curve_tol(torus.minor_radius()); - let out = Outward::Torus { - center: [cx, cy, cz], - axis: vnorm([nx, ny, nz]), - major: torus.major_radius(), - }; - Some((torus_faces(&torus), out, tol)) - } + // Truck builds a sphere as a full revolution and cannot trim it to the + // face's boundary, so a partial sphere renders as a whole ball. Route + // sphere faces to the bespoke sampler instead — it meshes only the + // boundary's parametric window (see `tess_sphere_face`). + "sphere-surface" => None, + // Like a sphere, truck builds a torus as a full revolution and cannot + // trim it to the face's boundary, so an open "C" tube renders as a whole + // closed ring. Route torus faces to the bespoke sampler, which meshes + // only the revolution arc between the tube's end caps (see + // `tess_torus_face` / `torus_phi_range`). + "torus-surface" => None, _ => None, } } @@ -450,50 +414,6 @@ fn cone_boundary_arc( (start, span) } -// ── Sphere face ────────────────────────────────────────────────────────────── - -fn sphere_faces(sphere: &SatSphereSurface) -> Vec { - let (cx, cy, cz) = sphere.center(); - let r = sphere.radius(); - let (px, py, pz) = sphere.pole(); - let (ux, uy, uz) = sphere.u_direction(); - - let center = Point3::new(cx, cy, cz); - let pole = norm(Vector3::new(px, py, pz)); - let mut perp = Vector3::new(ux, uy, uz); - if perp.magnitude2() < 1e-12 || perp.dot(pole).abs() > 0.999 { - perp = perpendicular(pole); - } else { - perp = norm(perp - pole * perp.dot(pole)); - } - - let top = builder::vertex(center + pole * r); - let meridian: Wire = builder::rsweep(&top, center, perp, Rad(std::f64::consts::PI)); - let shell: Shell = builder::cone(&meridian, pole, Rad(FULL)); - shell.face_iter().cloned().collect() -} - -// ── Torus face ─────────────────────────────────────────────────────────────── - -fn torus_faces(torus: &SatTorusSurface) -> Vec { - let (cx, cy, cz) = torus.center(); - let (nx, ny, nz) = torus.normal(); - let (ux, uy, uz) = torus.u_direction(); - let major = torus.major_radius(); - let minor = torus.minor_radius(); - - let center = Point3::new(cx, cy, cz); - let axis = norm(Vector3::new(nx, ny, nz)); - let udir = norm(Vector3::new(ux, uy, uz)); - let binormal = norm(axis.cross(udir)); - - let ring_center = center + udir * major; - let tube_start = builder::vertex(ring_center + axis * minor); - let tube: Wire = builder::rsweep(&tube_start, ring_center, binormal, Rad(FULL)); - let shell: Shell = builder::rsweep(&tube, center, axis, Rad(FULL)); - shell.face_iter().cloned().collect() -} - // ── Spline faces (NURBS) ───────────────────────────────────────────────────── /// Append meshes for every `spline-surface` face, reusing the truck @@ -577,16 +497,6 @@ fn norm(v: Vector3) -> Vector3 { } } -/// Any unit vector perpendicular to `v`. -fn perpendicular(v: Vector3) -> Vector3 { - let a = if v.x.abs() < 0.9 { - Vector3::unit_x() - } else { - Vector3::unit_y() - }; - norm(v.cross(a)) -} - // ── Vector helpers ([f64; 3]) ──────────────────────────────────────────────── #[inline] @@ -594,10 +504,6 @@ fn vsub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } #[inline] -fn vadd(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { - [a[0] + b[0], a[1] + b[1], a[2] + b[2]] -} -#[inline] fn vscale(a: [f64; 3], s: f64) -> [f64; 3] { [a[0] * s, a[1] * s, a[2] * s] } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 1a90ede0c..8212abec4 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -94,17 +94,41 @@ fn tessellate_sat( } "cone-surface" => { if let Some(cone) = SatConeSurface::from_record(surf_rec) { - tess_cone_face(sat, &face, &cone, lod, &mut verts, &mut normals, &mut indices); + tess_cone_face( + sat, + &face, + &cone, + lod, + &mut verts, + &mut normals, + &mut indices, + ); } } "sphere-surface" => { if let Some(sphere) = SatSphereSurface::from_record(surf_rec) { - tess_sphere_face(&sphere, lod, &mut verts, &mut normals, &mut indices); + tess_sphere_face( + sat, + &face, + &sphere, + lod, + &mut verts, + &mut normals, + &mut indices, + ); } } "torus-surface" => { if let Some(torus) = SatTorusSurface::from_record(surf_rec) { - tess_torus_face(&torus, lod, &mut verts, &mut normals, &mut indices); + tess_torus_face( + sat, + &face, + &torus, + lod, + &mut verts, + &mut normals, + &mut indices, + ); } } "spline-surface" => { @@ -144,22 +168,26 @@ fn tessellate_acis( facet_res: f64, isolines: usize, ) -> Option { - let mut set = - if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck(sat, name.clone(), color, facet_res) { - if std::env::var_os("OCS_TESS_DEBUG").is_some() { - let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0); - eprintln!("acis_tess[{name}]: truck ({tris} tris)"); - } - set - } else { - // truck couldn't rebuild the shell — fall back to the bespoke sampler. - let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res); - eprintln!( - "acis_tess[{name}]: manual fallback ({})", - if fallback.is_some() { "ok" } else { "empty" } - ); - fallback? - }; + let mut set = if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck( + sat, + name.clone(), + color, + facet_res, + ) { + if std::env::var_os("OCS_TESS_DEBUG").is_some() { + let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0); + eprintln!("acis_tess[{name}]: truck ({tris} tris)"); + } + set + } else { + // truck couldn't rebuild the shell — fall back to the bespoke sampler. + let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res); + eprintln!( + "acis_tess[{name}]: manual fallback ({})", + if fallback.is_some() { "ok" } else { "empty" } + ); + fallback? + }; // Attach the B-rep face-boundary edges plus ISOLINES on curved faces // (body-transformed, split into the double-single pair) so the solid's // wireframe shows real edges and curved faces read from any angle. @@ -190,8 +218,11 @@ fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument, isolines: usize } let (hx, hy, hz) = (x as f32, y as f32, z as f32); set.edge_verts.push([hx, hy, hz]); - set.edge_verts_low - .push([(x - hx as f64) as f32, (y - hy as f64) as f32, (z - hz as f64) as f32]); + set.edge_verts_low.push([ + (x - hx as f64) as f32, + (y - hy as f64) as f32, + (z - hz as f64) as f32, + ]); } } @@ -252,7 +283,11 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option { if (h_max - h_min).abs() < 1e-10 || theta_span.abs() < 1e-10 { return None; } - let tan_a = if cos_a.abs() > 1e-9 { sin_a / cos_a } else { 0.0 }; + let tan_a = if cos_a.abs() > 1e-9 { + sin_a / cos_a + } else { + 0.0 + }; Some(ConeFaceGeom { center: [cx, cy, cz], axis, @@ -311,8 +346,12 @@ fn cone_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec< let [cx, cy, cz] = g.center; let (r0, r1) = (g.radius + g.h_min * g.tan_a, g.radius + g.h_max * g.tan_a); for a in iso_params(g.theta_min, g.theta_span, g.full, count) { - out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r0, a, g.h_min)); - out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r1, a, g.h_max)); + out.push(cone_pt( + cx, cy, cz, g.axis, g.u_dir, g.v_dir, r0, a, g.h_min, + )); + out.push(cone_pt( + cx, cy, cz, g.axis, g.u_dir, g.v_dir, r1, a, g.h_max, + )); } } @@ -372,16 +411,12 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec let v = cross3(axis, u); let major = torus.major_radius(); let minor = torus.minor_radius(); - // The face boundary of a tube is just its two end caps, clustered at the - // ends — it never covers the revolution arc, so the boundary can't say which - // way round the tube runs (a limitation `torus_phi_range` shares). Draw the - // full revolution: correct for a closed or nearly-closed ring like this one, - // at the cost of over-drawing a short torus fillet, which a later - // winding-aware extent would tighten. + let (phi_min, phi_span, full) = torus_phi_range(sat, face, [cx, cy, cz], u, v); + let phi_total = if full { TAU } else { phi_span }; // Minor (cross-section) circles — constant revolution angle, full tube. const M: usize = 20; - for phi in iso_params(0.0, TAU, true, count) { + 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); @@ -390,17 +425,17 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec } } - // Major (ring-direction) arcs — constant tube angle, swept along the full - // revolution. The outer (θ=0) and inner (θ=π) circles are the torus's + // Major (ring-direction) arcs — constant tube angle, swept along the face's + // 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. - const RING_SEGS: usize = 64; + let ring_segs = (((phi_total / TAU) * 64.0).ceil() as usize).max(6); let n_ring = count.max(2); for k in 0..n_ring { let theta = TAU * (k as f64 / n_ring as f64); - for s in 0..RING_SEGS { - let p0 = TAU * (s as f64 / RING_SEGS as f64); - let p1 = TAU * ((s + 1) as f64 / RING_SEGS as f64); + for s in 0..ring_segs { + let p0 = phi_min + phi_total * (s as f64 / ring_segs as f64); + let p1 = phi_min + phi_total * ((s + 1) as f64 / ring_segs as f64); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, theta, p0)); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, theta, p1)); } @@ -434,22 +469,112 @@ fn sphere_param_range( let (theta_min, theta_span, full) = angular_span(&thetas); // Meridians converge at the poles, so pad the colatitude a touch toward each // pole the face reaches so the lines meet the rim rather than stopping short. - (theta_min, theta_span, full, (phi_min - 0.05).max(0.0), (phi_max + 0.05).min(PI)) + ( + theta_min, + theta_span, + full, + (phi_min - 0.05).max(0.0), + (phi_max + 0.05).min(PI), + ) } -/// Revolution-angle span of a torus face from its boundary polygon. -fn torus_phi_range(poly: &[[f64; 3]], center: [f64; 3], u: [f64; 3], v: [f64; 3]) -> (f64, f64, bool) { - if poly.len() < 2 { +/// Revolution-angle arc a torus face spans, walking all its boundary loops. +/// +/// A partial tube ends in two minor-circle caps sitting in constant-φ planes; +/// the arc between them is the opening. But the tube body can also carry +/// interior hole loops where another solid punches through it, so the widest +/// empty gap is *not* reliably the opening — a hole splits the body into wide +/// hole-free stretches that masquerade as it. Only a gap flanked by two end +/// caps is genuinely surface-free, so that is the opening; the body is the rest +/// of the turn. Returns `(body_start, body_span, full)`. +pub(crate) fn torus_phi_range( + sat: &SatDocument, + face: &SatFace, + center: [f64; 3], + u: [f64; 3], + v: [f64; 3], +) -> (f64, f64, bool) { + // Below this revolution-angle spread a loop is a minor circle sitting in a + // constant-φ plane — a tube end cap. Interference loops (where another solid + // punches through the tube) wander several degrees in φ, well above it. + const CAP_SPREAD: f64 = 0.15; // rad (~8.6°) + // Ignore sub-degree gaps *within* a cap's own point cluster; a genuine tube + // opening is far wider. + const MIN_OPENING: f64 = 0.02; // rad (~1.1°) + + let phi_of = |p: [f64; 3]| -> f64 { + let rel = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]; + dot3(rel, v).atan2(dot3(rel, u)).rem_euclid(TAU) + }; + // Angular spread of a set of φ values, wrap-aware: the turn minus the widest + // gap between them. A cap collapses to ~0; a hole loop keeps its real width. + let spread = |phis: &[f64]| -> f64 { + if phis.len() < 2 { + return 0.0; + } + let mut s = phis.to_vec(); + s.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mut gmax = 0.0f64; + for i in 0..s.len() { + let next = if i + 1 < s.len() { + s[i + 1] + } else { + s[0] + TAU + }; + gmax = gmax.max(next - s[i]); + } + TAU - gmax + }; + + // Every boundary point, tagged by whether its loop is a tube end cap. + let mut tagged: Vec<(f64, bool)> = Vec::new(); + let mut lp = face.first_loop(); + let mut seen: HashSet = HashSet::default(); + while !lp.is_null() && seen.insert(lp.0) { + let Some(lr) = sat.resolve(lp) else { break }; + let Some(sl) = SatLoop::from_record(lr) else { + break; + }; + let poly = collect_loop_polygon(sat, &sl, 48); + lp = sl.next_loop(); + if poly.is_empty() { + continue; + } + let phis: Vec = poly.iter().map(|&p| phi_of(p)).collect(); + let is_cap = spread(&phis) < CAP_SPREAD; + for phi in phis { + tagged.push((phi, is_cap)); + } + } + if tagged.len() < 2 { return (0.0, TAU, true); } - let phis: Vec = poly - .iter() - .map(|&p| { - let rel = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]; - dot3(rel, v).atan2(dot3(rel, u)) - }) - .collect(); - angular_span(&phis) + tagged.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + // The opening is the arc with no surface: a gap flanked on *both* sides by + // end-cap points (a hole never bounds the opening). Of those, the tube's own + // opening is the narrowest above the intra-cluster noise floor; the body is + // the rest of the turn. No cap-cap gap ⇒ the tube closes into a full ring. + let n = tagged.len(); + let mut opening_gap = f64::MAX; + let mut body_start = 0.0; + for i in 0..n { + let (phi_i, cap_i) = tagged[i]; + let (phi_j, cap_j) = if i + 1 < n { + tagged[i + 1] + } else { + (tagged[0].0 + TAU, tagged[0].1) + }; + let gap = phi_j - phi_i; + if cap_i && cap_j && gap > MIN_OPENING && gap < opening_gap { + opening_gap = gap; + body_start = phi_j.rem_euclid(TAU); // body resumes past the far cap + } + } + if opening_gap == f64::MAX { + return (0.0, TAU, true); + } + (body_start, TAU - opening_gap, false) } /// Reduce a set of angles to a `(min, span, full)` arc. Mirrors `angular_range`'s @@ -465,7 +590,11 @@ fn angular_span(angles: &[f64]) -> (f64, f64, bool) { let mut gap_max = 0.0; let mut gap_at = 0usize; for i in 0..a.len() { - let next = if i + 1 < a.len() { a[i + 1] } else { a[0] + TAU }; + let next = if i + 1 < a.len() { + a[i + 1] + } else { + a[0] + TAU + }; let gap = next - a[i]; if gap > gap_max { gap_max = gap; @@ -513,7 +642,11 @@ fn collect_curved_gens( let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32); ( [hx, hy, hz], - [(wx - hx as f64) as f32, (wy - hy as f64) as f32, (wz - hz as f64) as f32], + [ + (wx - hx as f64) as f32, + (wy - hy as f64) as f32, + (wz - hz as f64) as f32, + ], ) }; let mut out = Vec::new(); @@ -523,7 +656,9 @@ fn collect_curved_gens( }; match surf.entity_type.as_str() { "cone-surface" => { - let Some(g) = cone_face_geom(sat, &face) else { continue }; + let Some(g) = cone_face_geom(sat, &face) else { + continue; + }; let base_local = [ g.center[0] + g.h_min * g.axis[0], g.center[1] + g.h_min * g.axis[1], @@ -545,7 +680,9 @@ fn collect_curved_gens( }); } "sphere-surface" => { - let Some(sphere) = SatSphereSurface::from_record(surf) else { continue }; + let Some(sphere) = SatSphereSurface::from_record(surf) else { + continue; + }; let (cx, cy, cz) = sphere.center(); let pole = norm3([sphere.pole().0, sphere.pole().1, sphere.pole().2]); let u = norm3([ @@ -573,7 +710,9 @@ fn collect_curved_gens( }); } "torus-surface" => { - let Some(torus) = SatTorusSurface::from_record(surf) else { continue }; + let Some(torus) = SatTorusSurface::from_record(surf) else { + continue; + }; let (cx, cy, cz) = torus.center(); let axis = norm3([torus.normal().0, torus.normal().1, torus.normal().2]); let u = norm3([ @@ -582,8 +721,7 @@ fn collect_curved_gens( torus.u_direction().2, ]); let v = cross3(axis, u); - let poly = collect_face_polygon(sat, &face, 48); - let (pmin, pspan, full) = torus_phi_range(&poly, [cx, cy, cz], u, v); + let (pmin, pspan, full) = torus_phi_range(sat, &face, [cx, cy, cz], u, v); let (center, center_low) = place([cx, cy, cz]); out.push(CurvedGen::Torus { center, @@ -710,7 +848,12 @@ pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f // (`$-1`) and the trailing rotate/reflect/shear flags aren't floats, so // collecting float tokens skips them — reading by raw token index would // be thrown off by the leading pointer. - let v: Vec = t.tokens.iter().filter_map(|tok| tok.as_float()).take(13).collect(); + let v: Vec = t + .tokens + .iter() + .filter_map(|tok| tok.as_float()) + .take(13) + .collect(); if v.len() < 13 { return None; } @@ -781,10 +924,18 @@ pub(crate) fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] { if !x.is_finite() || !y.is_finite() { continue; } - if x < min_x { min_x = x; } - if y < min_y { min_y = y; } - if x > max_x { max_x = x; } - if y > max_y { max_y = y; } + if x < min_x { + min_x = x; + } + if y < min_y { + min_y = y; + } + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } } [min_x, min_y, max_x, max_y] } @@ -804,7 +955,12 @@ fn parse_acis( } /// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels. -pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { +pub fn tessellate_region( + region: &Region, + color: [f32; 4], + facet_res: f64, + isolines: usize, +) -> Option { let sat = parse_acis( || region.parse_sat(), region.acis_data.is_binary, @@ -815,7 +971,12 @@ pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64, isoli } /// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels. -pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { +pub fn tessellate_body( + body: &Body, + color: [f32; 4], + facet_res: f64, + isolines: usize, +) -> Option { let sat = parse_acis( || body.parse_sat(), body.acis_data.is_binary, @@ -849,7 +1010,12 @@ pub fn tessellate_surface( /// Returns `None` when the entity has no parseable SAT data or produces no /// triangles (e.g. the solid uses only unsupported surface types). /// `facet_res` mirrors the header FACETRES variable (0.01–10.0). -pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { +pub fn tessellate_solid3d( + solid: &Solid3D, + color: [f32; 4], + facet_res: f64, + isolines: usize, +) -> Option { let sat = parse_acis( || solid.parse_sat(), solid.acis_data.is_binary, @@ -871,7 +1037,11 @@ pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64, isol /// /// 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) -> Vec<[f64; 3]> { +pub(crate) fn collect_face_polygon( + sat: &SatDocument, + face: &SatFace, + circ_segs: usize, +) -> Vec<[f64; 3]> { let Some(loop_rec) = sat.resolve(face.first_loop()) else { return vec![]; }; @@ -1321,7 +1491,11 @@ fn angular_range( 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 b = if i + 1 < n { + angles[i + 1] + } else { + angles[0] + TAU + }; let gap = b - a; if gap > max_gap { max_gap = gap; @@ -1368,11 +1542,7 @@ pub(crate) fn cone_axis_span( let d = [ec.0 - center[0], ec.1 - center[1], ec.2 - center[2]]; let h = dot3(d, axis); // Radial offset from the axis line: must be ~0 to be coaxial. - let radial = [ - d[0] - h * axis[0], - d[1] - h * axis[1], - d[2] - h * axis[2], - ]; + let radial = [d[0] - h * axis[0], d[1] - h * axis[1], d[2] - h * axis[2]]; let radial_len = dot3(radial, radial).sqrt(); let n = e.normal(); let n_dot = dot3(norm3([n.0, n.1, n.2]), axis).abs(); @@ -1407,6 +1577,8 @@ pub(crate) fn cone_axis_span( // ── Sphere face ─────────────────────────────────────────────────────────────── pub(crate) fn tess_sphere_face( + sat: &SatDocument, + face: &SatFace, sphere: &SatSphereSurface, lod: LodConfig, verts: &mut Vec<[f32; 3]>, @@ -1424,13 +1596,30 @@ pub(crate) fn tess_sphere_face( 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 (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 { + (0.0, TAU) + } else { + (t_min, t_min + t_span) + }; + let (phi_lo, phi_hi) = if full { + (0.0, std::f64::consts::PI) + } else { + (p_min, p_max) + }; + for j in 0..nv { - let phi0 = std::f64::consts::PI * (j as f64 / nv as f64); // 0..π - let phi1 = std::f64::consts::PI * ((j + 1) as f64 / nv as f64); + 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); for i in 0..nu { - let theta0 = TAU * (i as f64 / nu as f64); - let theta1 = TAU * ((i + 1) as f64 / nu as f64); + let theta0 = theta_lo + (theta_hi - theta_lo) * (i as f64 / nu as f64); + let theta1 = theta_lo + (theta_hi - theta_lo) * ((i + 1) as f64 / nu as f64); let n00 = sphere_dir(pole, u_dir, v_dir, theta0, phi0); let n10 = sphere_dir(pole, u_dir, v_dir, theta0, phi1); @@ -1473,6 +1662,8 @@ fn sphere_dir(pole: [f64; 3], u_dir: [f64; 3], v_dir: [f64; 3], theta: f64, phi: // ── Torus face ──────────────────────────────────────────────────────────────── pub(crate) fn tess_torus_face( + sat: &SatDocument, + face: &SatFace, torus: &SatTorusSurface, lod: LodConfig, verts: &mut Vec<[f32; 3]>, @@ -1489,11 +1680,17 @@ pub(crate) fn tess_torus_face( let minor_r = torus.minor_radius(); let nu = lod.grid_u.max(3); // around the tube - let nv = lod.grid_v.max(3); // around the torus + + // 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); for j in 0..nv { - let phi0 = TAU * (j as f64 / nv as f64); - let phi1 = TAU * ((j + 1) as f64 / nv as f64); + let phi0 = phi_start + phi_total * (j as f64 / nv as f64); + let phi1 = phi_start + phi_total * ((j + 1) as f64 / nv as f64); for i in 0..nu { let theta0 = TAU * (i as f64 / nu as f64); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index b919273f0..5149406ef 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1608,7 +1608,7 @@ impl Pipeline { }; for set in sets { let color = set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]); - let mut mk = |w: glam::DVec3| -> MeshVertex { + let mk = |w: glam::DVec3| -> MeshVertex { let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32); MeshVertex { position: [hx, hy, hz],