diff --git a/Cargo.lock b/Cargo.lock index f6e28f061..cb185d3e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 73f4ec15d..0f0ae867e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index 1ea70e228..fe1e28c1f 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -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 { if loops.is_empty() { return None; } - let build_wire = |pts: &[[f64; 3]], reverse: bool| -> Option { - 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()) - }; // 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 { // test robust against sampling noise on small holes. (#123) let outer_n = loop_normal(&loops[outer_idx]); let mut wires: Vec = 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 { + 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 = 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 = 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`. diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 8f4fbd401..b37eb7b44 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -409,7 +409,7 @@ fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option { 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 = 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); diff --git a/src/scene/convert/spline_tess.rs b/src/scene/convert/spline_tess.rs index 4d461206a..eae3f568f 100644 --- a/src/scene/convert/spline_tess.rs +++ b/src/scene/convert/spline_tess.rs @@ -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, std::ops::Bound)) -> (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 { + 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 Option { + 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