From dc998fe46bc0b5f0115d03662c9e6474f15c117c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 10 Aug 2026 10:18:53 +0300 Subject: [PATCH] refactor(tess): move solid sampling to kernel Remove local face, edge, spline, sweep, loft, and silhouette samplers. Meshes and overlays share the kernel tolerance and edge schedule. --- Cargo.lock | 44 +- Cargo.toml | 2 +- crates/ocs_plugin_api/Cargo.toml | 2 +- src/entities/solid3d.rs | 82 +- src/scene/convert/acis_kernel.rs | 272 ++- src/scene/convert/mod.rs | 1 - src/scene/convert/solid3d_tess.rs | 2662 ++--------------------------- src/scene/convert/spline_tess.rs | 454 ----- src/scene/convert/tessellate.rs | 36 +- src/scene/mod.rs | 148 +- src/scene/model/mesh_model.rs | 80 +- src/scene/model/solid_model.rs | 47 +- src/scene/model/sweep_model.rs | 292 +--- src/scene/modify.rs | 38 +- src/scene/pipeline/mod.rs | 188 +- 15 files changed, 410 insertions(+), 3938 deletions(-) delete mode 100644 src/scene/convert/spline_tess.rs diff --git a/Cargo.lock b/Cargo.lock index 7c6d4959..e60125ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,7 +6,7 @@ version = 4 name = "OpenCADStudio" version = "0.9.4" dependencies = [ - "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=1ffe44e)", + "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)", "ashpd", "bincode", "bytemuck", @@ -70,11 +70,11 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadifc" version = "0.5.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=1ffe44e#1ffe44ea986628a9915f3e4f0e0b1c433a131c3d" +source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae#6059baed781c6399d8259aa85c19be803ba95205" dependencies = [ - "acadrust", + "acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=36e841f)", "base64", - "cadkernel", + "cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb)", "serde", "serde_json", "sha2 0.10.9", @@ -86,15 +86,38 @@ name = "acadifc" version = "0.5.0" source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c" dependencies = [ - "acadrust", + "acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f)", "base64", - "cadkernel", + "cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git)", "serde", "serde_json", "sha2 0.10.9", "thiserror 1.0.69", ] +[[package]] +name = "acadrust" +version = "0.4.1" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=36e841f#36e841feeebe706b7f8dd604d273e34032fda764" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "bitflags 2.13.1", + "byteorder", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "memmap2", + "nom 7.1.3", + "once_cell", + "rayon", + "ryu", + "serde", + "thiserror 1.0.69", + "web-time", +] + [[package]] name = "acadrust" version = "0.4.1" @@ -904,11 +927,16 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#860b9df7e5fc4a495334a6329560a4cde33659db" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb#6f34deb57eedf0dc5908bf225917f19c9707682d" dependencies = [ "cavalier_contours", ] +[[package]] +name = "cadkernel" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/cadkernel.git#860b9df7e5fc4a495334a6329560a4cde33659db" + [[package]] name = "calloop" version = "0.13.0" @@ -3760,7 +3788,7 @@ dependencies = [ name = "ocs_plugin_api" version = "0.1.0" dependencies = [ - "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396)", + "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)", "bincode", "getrandom 0.2.17", "interprocess", diff --git a/Cargo.toml b/Cargo.toml index bd8c5505..ef60849e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ env_logger = "0.11" # The CAD stack is reached through acadifc, which re-exports the codec and # the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…` # paths keep resolving. -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "1ffe44e", features = ["serde", "offset"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", features = ["serde", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index e20d48c2..a2d3b544 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -9,7 +9,7 @@ license = "GPL-3.0-only" # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", optional = true, features = ["serde"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index e5abbafe..af5c8c2b 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -2,9 +2,8 @@ // // Geometry lives in ACIS data — we cannot edit it via the properties panel. // We expose the point_of_reference as a translate grip and show ACIS size -// as read-only info. Grip translate also updates wire points so the wire -// fallback stays in sync; the caller (scene/mod.rs apply_grip) translates -// the MeshModel vertices to match. +// as read-only info. Grip translate also updates stored wire points; the +// caller translates the mesh vertices to match. use acadrust::entities::{Body, Region, Solid3D, Surface}; use acadrust::kernel::space::polygon; @@ -745,11 +744,7 @@ impl PropertyEditable for Surface { // ── Accessors for the Solid3D / Region / Body trio ───────────────────────── // -// These three entity types share a common subset of fields (ACIS data -// + point_of_reference + wires fallback). Code that needs to treat them -// uniformly (mesh tess dispatch, fallback wires, grip translate) used -// to repeat a three-arm `match entity` block at every callsite — the -// helpers below collapse those to a single call. +// These entity types share ACIS data and a point of reference. use crate::scene::model::mesh_model::MeshLodSet; use crate::scene::convert::solid3d_tess; @@ -766,77 +761,6 @@ pub fn point_of_reference(e: &EntityType) -> Option<&Vector3> { } } -/// Pre-stored edge-wire fallback list (used when the SAT/SAB kernel -/// can't produce a mesh — drawings authored by SOLVIEW / 3DPLOT carry -/// these explicitly). -pub fn fallback_wires(e: &EntityType) -> Option<&[acadrust::entities::Wire]> { - match e { - EntityType::Solid3D(s) => Some(&s.wires), - EntityType::Region(r) => Some(&r.wires), - EntityType::Body(b) => Some(&b.wires), - EntityType::Surface(s) => Some(&s.wires), - _ => None, - } -} - -pub fn wire_point( - wire: &acadrust::entities::Wire, - point: &acadrust::types::Vector3, -) -> acadrust::types::Vector3 { - if !wire.has_transform { - return *point; - } - let x = point.x * wire.scale.x; - let y = point.y * wire.scale.y; - let z = point.z * wire.scale.z; - acadrust::types::Vector3::new( - wire.translation.x - + wire.x_axis.x * x - + wire.y_axis.x * y - + wire.z_axis.x * z, - wire.translation.y - + wire.x_axis.y * x - + wire.y_axis.y * y - + wire.z_axis.y * z, - wire.translation.z - + wire.x_axis.z * x - + wire.y_axis.z * y - + wire.z_axis.z * z, - ) -} - -/// Whether every ACIS face uses a surface family the mesh pipeline can decode. -/// Unsupported or unresolved faces must keep their display-cache wires visible; -/// otherwise a parseable but incomplete shell looks like a valid solid. -pub fn acis_has_complete_surface_support(e: &EntityType) -> bool { - let sat = match e { - EntityType::Solid3D(s) => s.acis_data.parse(), - EntityType::Region(r) => r.acis_data.parse(), - EntityType::Body(b) => b.acis_data.parse(), - EntityType::Surface(s) => s.acis_data.parse(), - _ => None, - }; - let Some(sat) = sat else { - return false; - }; - let faces = sat.faces(); - !faces.is_empty() - && faces.iter().all(|face| { - sat.resolve(face.surface()).is_some_and(|surface| { - matches!( - surface.entity_type.as_str(), - "plane-surface" - | "cone-surface" - | "sphere-surface" - | "torus-surface" - | "spline-surface" - | "meshsurf-surface" - | "bs3-surface" - ) - }) - }) -} - /// Build material-aware shaded geometry for every standard 3-D solid/surface /// and mesh family, returning `None` when decoded geometry is unusable. pub fn tessellate_volume( diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index bd6f89b0..16c12a9c 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -6,112 +6,143 @@ //! the kernel for triangles, rather than to re-derive each surface's extent by //! sampling it. //! -//! # Why it can still fall short -//! -//! A face on a surface the kernel does not model, a curve it has no form for, -//! a pointer graph that does not hold together: [`lift`] reports each as a -//! [`Loss`] rather than quietly dropping it. What comes back then is a body -//! with faces missing, and the mesh it makes has holes — which is why the -//! result is marked incomplete and the caller keeps its own sampler for those. -//! -//! Saying so is the point. A partial mesh that claimed to be whole would show -//! a solid with a wall missing and nothing to suggest anything was wrong. +//! Lift and tessellation failures are reported as an incomplete result. use acadrust::acis::lift; use acadrust::entities::acis::SatDocument; use acadrust::kernel::brep; use crate::scene::convert::solid3d_tess::{body_transform, finalize_mesh}; -use crate::scene::model::mesh_model::MeshLodSet; +use crate::scene::model::mesh_model::{CurvedGen, MeshLodSet}; -/// How far a triangle may sit from the surface it lies on, as a fraction of -/// that surface's own radius. -/// -/// A fraction rather than a length, because a length carries an assumption -/// about the drawing's units: a centimetre of sag is nothing on a pipeline -/// and is the whole of a bolt. -/// -/// The *same* fraction the feature edges use, and deliberately so. Those edges -/// are drawn over these faces, so sampling the two differently leaves the wire -/// cutting across a facet instead of running along its corners — the rim of a -/// cylinder standing proud of the wall it bounds. Sharing the constant is what -/// keeps them from drifting apart when one is tuned. -use crate::scene::convert::solid3d_tess::EDGE_CHORD_FRAC as CHORD_FRAC; +/// Relative chord tolerance, resolved once per body. +const CHORD_FRAC: f64 = 0.002; -/// What counts as the same point when the kernel reads a body over. -/// -/// A micrometre, in a drawing measured in metres. Not slackness: an edge is -/// shared by two faces, and in a real file it cannot sit exactly on both, -/// because the two surfaces were fitted separately and written to finite -/// precision. Asked for exactness the kernel decides the edge is not on its -/// own plane, declines to project it, and the face is dropped — twenty-six -/// walls of one building went missing at a nanometre that no drawing means. -/// -/// Loosening further buys almost nothing: a hundredth of this recovers one -/// more face in sixty thousand, and past that the tolerance would start -/// accepting geometry that really is wrong. +/// ACIS topology fit tolerance. const TOL: f64 = 1e-6; -/// Tessellate an ACIS document by lifting it into the kernel. -/// -/// `None` when nothing in the document lifts at all. The result's `complete` -/// flag says whether every face made it; a caller with a fallback sampler -/// uses it to decide whether to run one. +/// Tessellate an ACIS document through the kernel. pub fn tessellate_sat( document: &SatDocument, name: String, color: [f32; 4], facet_res: f64, + isolines: usize, ) -> Option { let (bodies, loss) = lift(document); if bodies.is_empty() { return None; } - // `facet_res` is a resolution multiplier, not a length — the same one - // `scale_lod` divides the fallback sampler's chord fraction by. Using it - // as a sag made every solid as coarse as its own boundary: at the default - // it asked for a whole world unit of departure, which on anything smaller - // than that means no subdivision at all, and a pipe came out with as many - // sides as its rim had points. - // - // It is not applied here at all. The feature edges these faces are drawn - // under are built once at highest detail and never scaled, so scaling the - // faces would pull the two apart again at any setting but one. - let _ = facet_res; - let frac = CHORD_FRAC; + let mut placed_bodies = Vec::with_capacity(bodies.len()); + for body in bodies { + let source = body.provenance.source()?; + let transform = body_transform(document, source.index() as usize).ok()?; + let placed = if let Some((matrix, translation, scale)) = transform { + let placement = brep::Placement { + x_axis: [scale * matrix[0], scale * matrix[1], scale * matrix[2]], + y_axis: [scale * matrix[3], scale * matrix[4], scale * matrix[5]], + z_axis: [scale * matrix[6], scale * matrix[7], scale * matrix[8]], + origin: translation, + }; + brep::transform(&body, &placement)? + } else { + body + }; + placed_bodies.push(placed); + } + let bodies = placed_bodies; + let resolution = if facet_res.is_finite() && facet_res > 0.0 { + facet_res.clamp(0.01, 10.0) + } else { + 1.0 + }; + let frac = CHORD_FRAC / resolution; // Positions stay f64 until `finalize_mesh` splits them into the coarse // and fine pair, so a solid at survey coordinates keeps its millimetres. let mut positions: Vec<[f64; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec = Vec::new(); + let mut edges: Vec<[f64; 3]> = Vec::new(); + let mut triangle_materials = Vec::new(); + let mut triangle_colors = Vec::new(); + let mut curved_gens = Vec::new(); + let face_materials: std::collections::HashMap = document + .records + .iter() + .filter(|record| record.entity_type == "material-adesk-attrib") + .filter_map(|record| { + let owner = record.token_pointer(2)?.0; + let handle = record.token(3)?.as_integer()?; + (owner >= 0 && handle > 0) + .then(|| (owner, acadrust::Handle::new(handle as u64))) + }) + .collect(); + let face_colors: std::collections::HashMap = document + .records + .iter() + .filter(|record| record.entity_type == "color-adesk-attrib") + .filter_map(|record| { + let owner = record.token_pointer(2)?.0; + let value = record.token(3)?.as_integer()?; + let source = if (1..=255).contains(&value) { + acadrust::Color::from_index(value as i16) + } else if value > 257 { + acadrust::Color::from_true_color_value(value as i32) + } else { + return None; + }; + let mut rgba = crate::scene::convert::tess_util::aci_to_rgba(&source); + rgba[3] = color[3]; + Some((owner, rgba)) + }) + .collect(); // A face the kernel holds but cannot express in its surface's own // parameters leaves a hole, the same as one that never lifted — so both // are counted before calling the mesh whole. let mut undrawn = 0usize; + let tolerance = brep::mesh::TessellationTolerance::relative(frac, TOL) + .with_isolines(isolines); for body in &bodies { - // What a flat face is sampled against: it never departs from its own - // plane, so only its boundary arcs care, and the body's own size is - // the nearest thing to a radius they have. - let span = body_span(body); - for face in body.face_keys() { - let sag = frac * face_radius(body, face).unwrap_or(span); - let Some(mesh) = brep::mesh::face(body, face, sag, TOL) else { - undrawn += 1; - continue; - }; - let base = positions.len() as u32; - positions.extend_from_slice(&mesh.positions); - normals.extend( - mesh.normals - .iter() - .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]), - ); - indices.extend( - mesh.triangles - .iter() - .flat_map(|t| [base + t[0] as u32, base + t[1] as u32, base + t[2] as u32]), - ); + let tessellation = brep::mesh::tessellate(body, tolerance); + undrawn += tessellation.missing_faces.len(); + for face in &tessellation.triangle_faces { + let record = body + .faces + .get(*face) + .and_then(|face| face.provenance.source()) + .map(|source| source.index() as i32); + triangle_materials.push(record.and_then(|record| face_materials.get(&record).copied())); + triangle_colors.push(record.and_then(|record| face_colors.get(&record).copied())); + } + curved_gens.push(CurvedGen { + source: tessellation.silhouette_source(), + }); + let base = positions.len() as u32; + positions.extend_from_slice(&tessellation.mesh.positions); + normals.extend( + tessellation + .mesh + .normals + .iter() + .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]), + ); + indices.extend(tessellation.mesh.triangles.iter().flat_map(|triangle| { + [ + base + triangle[0] as u32, + base + triangle[1] as u32, + base + triangle[2] as u32, + ] + })); + for edge in tessellation.edges { + for segment in edge.positions.windows(2) { + edges.extend_from_slice(segment); + } + } + for isoline in tessellation.isolines { + for segment in isoline.positions.windows(2) { + edges.extend_from_slice(segment); + } } } if indices.is_empty() { @@ -127,81 +158,31 @@ pub fn tessellate_sat( positions, normals, indices, - Vec::new(), - Vec::new(), + triangle_materials, + triangle_colors, color, - body_transform(document), + None, )); + set.curved_gens = curved_gens; + for point in edges { + let high = [point[0] as f32, point[1] as f32, point[2] as f32]; + set.edge_verts.push(high); + set.edge_verts_low.push([ + (point[0] - high[0] as f64) as f32, + (point[1] - high[1] as f64) as f32, + (point[2] - high[2] as f64) as f32, + ]); + } set.complete = loss.is_empty() && undrawn == 0; Some(set) } -/// The radius of the surface a face lies on, where it has one. -/// -/// A torus is measured by its tube rather than its ring: the tube is the -/// tighter bend, and sampling to the ring would leave the section a hexagon. -fn face_radius(body: &brep::Body, face: brep::FaceKey) -> Option { - let surface = body.surfaces.get(body.faces.get(face)?.surface)?; - match surface { - brep::Surface::Plane(_) => None, - brep::Surface::Cylinder(cylinder) => Some(cylinder.radius), - brep::Surface::Cone(cone) => Some(cone.radius), - brep::Surface::Sphere(sphere) => Some(sphere.radius), - brep::Surface::Torus(torus) => Some(torus.minor_radius), - } -} - -/// How big a body is, from the corners it is built on. -fn body_span(body: &brep::Body) -> f64 { - let mut low = [f64::INFINITY; 3]; - let mut high = [f64::NEG_INFINITY; 3]; - for (_, vertex) in body.vertices.iter() { - for axis in 0..3 { - low[axis] = low[axis].min(vertex.point[axis]); - high[axis] = high[axis].max(vertex.point[axis]); - } - } - if low[0] > high[0] { - return 1.0; - } - (0..3) - .map(|axis| high[axis] - low[axis]) - .fold(0.0_f64, f64::max) - .max(1e-9) -} - -/// The edges of every body in an ACIS document, as polylines. -/// -/// What draws a solid's wireframe and what a click hit-tests against. Taken -/// from the kernel's own curves rather than from the mesh, so a rim is a -/// circle sampled to tolerance instead of whatever the triangulation left -/// along it. -/// -/// Not called yet: the solid tessellator keeps its own feature-edge pass, -/// which also carries isolines. Here because it is the kernel's answer to the -/// same question, and the two should converge on it. -#[allow(dead_code)] -pub fn edge_polylines(document: &SatDocument, sag: f64) -> Vec> { - let (bodies, _) = lift(document); - let sag = if sag > 0.0 { sag } else { CHORD_FRAC }; - let placement = body_transform(document); - bodies - .iter() - .flat_map(|body| brep::edge_polylines(body, sag)) - .map(|polyline| { - polyline - .into_iter() - .map(|point| placed(point, placement)) - .collect() - }) - .collect() -} - /// A body-local point moved to where the body sits. /// /// ACIS treats points as row vectors — `p' = scale·(p·M) + T` — so the stored /// 3×3 is indexed transposed from a column-vector multiply. Getting that the /// wrong way round mirrors a placed solid rather than moving it. +#[cfg(test)] fn placed(point: [f64; 3], xform: Option<([f64; 9], [f64; 3], f64)>) -> [f64; 3] { let Some((m, translation, scale)) = xform else { return point; @@ -275,17 +256,4 @@ mod tests { assert!(sides(CHORD_FRAC) < 96.0, "{}", sides(CHORD_FRAC)); } - /// And it is the edges' own density, so the wire drawn over a face lands - /// on the facet corners rather than cutting across them. - #[test] - fn a_face_is_sampled_as_finely_as_the_edges_over_it() { - let rim = crate::scene::convert::solid3d_tess::edge_arc_segs( - 5.0, - std::f64::consts::TAU, - ) as f64; - let wall = sides(CHORD_FRAC); - assert!((rim - wall).abs() <= 1.0, "rim {rim} vs wall {wall}"); - } - - } diff --git a/src/scene/convert/mod.rs b/src/scene/convert/mod.rs index b9acb433..2a4bb17e 100644 --- a/src/scene/convert/mod.rs +++ b/src/scene/convert/mod.rs @@ -8,4 +8,3 @@ pub(crate) mod tess; pub mod proxy_graphics; pub mod tess_util; pub mod solid3d_tess; -pub mod spline_tess; diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 209f32fb..9098c5cd 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,277 +1,8 @@ -// ACIS SAT → MeshModel tessellation for Solid3D (3DSOLID) entities. -// -// Strategy: -// • plane-surface faces → collect coedge-loop polygon, fan-triangulate. -// • cone-surface faces → sample a parametric grid (handles both cylinders -// and true cones). -// • sphere-surface faces → sample a boundary-clipped UV grid. -// • torus-surface faces → sample a boundary-clipped UV grid. -// -// Coverage is tracked explicitly. Unsupported or malformed faces retain -// feature/display wires, and partial shells are marked non-complete so solid -// editing never mistakes them for closed topology. - -use rustc_hash::FxHashSet as HashSet; -use rustc_hash::FxHashMap; -use std::f64::consts::TAU; - -use acadrust::entities::acis::types::Sense; -use acadrust::entities::acis::{ - SabReader, SatCoedge, SatConeSurface, SatDocument, SatEdge, SatEllipseCurve, SatFace, - SatIntCurve, SatLoop, SatPlaneSurface, SatPoint, SatPointer, SatSphereSurface, - SatTorusSurface, SatVertex, -}; +use acadrust::entities::acis::{SabReader, SatBody, SatDocument}; use acadrust::entities::{Body, Region, Solid3D}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; -// ── Curved-surface sampling — SINGLE TUNING POINT ──────────────────────────── -// -// Every curved 3-D face (sphere / cone / torus / spline), curved feature edge -// and isoline samples at a density derived from a chord-height tolerance — the -// same model the 2-D circle/arc/ellipse wires use (see `tess_util::arc_segments`): -// the segment count tracks the arc's own radius and span at a bounded relative -// chord error, so a partial arc samples proportionally and facet error is -// size-independent. Every knob lives in this block — edit here to trade mesh -// density against the triangle budget across the whole solid tessellator. - -/// Feature edges & isolines are built once at highest detail; this chord-height -/// fraction of the curve radius sets their sampling density (~0.002 ⇒ ~50 -/// segments per full circle). -pub(crate) const EDGE_CHORD_FRAC: f64 = 0.002; -/// Boundary-loop sampling for parameter-range classification (which arc of a -/// sphere/torus a face covers): a fine fraction so the classification is -/// accurate; the points are not rendered. -pub(crate) const BOUNDARY_CHORD_FRAC: f64 = 0.002; - -/// Per-LOD curved-surface sampling tolerance. A LOD is now just a chord-height -/// tolerance (fraction of the local radius); segment counts derive from it plus -/// the arc's own radius and span, so density is adaptive rather than a fixed -/// grid. Smaller fraction = finer mesh = more triangles. -#[derive(Copy, Clone, Debug)] -pub struct LodConfig { - /// Chord-height tolerance as a fraction of the local radius. - pub chord_frac: f64, -} - -impl LodConfig { - /// LOD 0 — full resolution (~0.5 % radius ⇒ ~32 segments per full circle, - /// matching the pre-tolerance grid baseline). - pub const HIGH: LodConfig = LodConfig { chord_frac: 0.005 }; - /// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal. - pub const MID: LodConfig = LodConfig { chord_frac: 0.02 }; - /// LOD 2 — quarter-resolution. Use below ~50 px. - pub const LOW: LodConfig = LodConfig { chord_frac: 0.08 }; - /// Returns the three LOD configs in `[high, mid, low]` order — matches - /// the `MeshLodSet::lods` slot ordering. - pub const fn all() -> [LodConfig; 3] { - [Self::HIGH, Self::MID, Self::LOW] - } - - /// Segment count spanning `span_abs` radians of an arc of `radius` at this - /// LOD's chord tolerance. Floor 2 — an open grid patch needs only a step. - pub fn arc_segs(&self, radius: f64, span_abs: f64) -> usize { - crate::scene::convert::tess_util::arc_segments_floored( - radius.abs(), - span_abs, - radius.abs() * self.chord_frac, - 2, - ) as usize - } - - /// Segment count around a full circle of `radius` at this LOD. Floor 8 so a - /// closed cross-section (a tube / minor circle) still reads as round. - pub fn circle_segs(&self, radius: f64) -> usize { - crate::scene::convert::tess_util::arc_segments_floored( - radius.abs(), - TAU, - radius.abs() * self.chord_frac, - 8, - ) as usize - } -} - -/// Segment count for a feature edge / isoline arc of `radius` spanning -/// `span_abs`, at the shared [`EDGE_CHORD_FRAC`] tolerance. Floor 4. -pub(crate) fn edge_arc_segs(radius: f64, span_abs: f64) -> usize { - crate::scene::convert::tess_util::arc_segments_floored( - radius.abs(), - span_abs, - radius.abs() * EDGE_CHORD_FRAC, - 4, - ) as usize -} - -/// Sample count for a curve with no analytic radius (a spline edge / surface): -/// the unit-circle segment count at chord fraction `frac`, used as a nominal -/// density that still tracks the LOD. -pub(crate) fn nominal_segs(frac: f64) -> usize { - crate::scene::convert::tess_util::arc_segments_floored(1.0, TAU, frac, 8) as usize -} - -// ── Public entry point ──────────────────────────────────────────────────────── - -/// Tessellate a SAT document into mesh buffers — shared by all ACIS entities. -/// Vertices accumulate in f64 and `finalize_mesh` splits them into the -/// double-single pair with the body placement (`xform`) applied. -fn tessellate_sat( - sat: &SatDocument, - name: String, - color: [f32; 4], - lod: LodConfig, - xform: Option<([f64; 9], [f64; 3], f64)>, -) -> Option<(MeshModel, bool)> { - let mut verts: Vec<[f64; 3]> = Vec::new(); - let mut normals: Vec<[f32; 3]> = Vec::new(); - let mut indices: Vec = Vec::new(); - let face_materials: FxHashMap = sat - .records - .iter() - .filter(|record| record.entity_type == "material-adesk-attrib") - .filter_map(|record| { - let owner = record.token_pointer(2)?.0; - let handle = record.token(3)?.as_integer()?; - (owner >= 0 && handle > 0) - .then(|| (owner, acadrust::Handle::new(handle as u64))) - }) - .collect(); - let face_colors: FxHashMap = sat - .records - .iter() - .filter(|record| record.entity_type == "color-adesk-attrib") - .filter_map(|record| { - let owner = record.token_pointer(2)?.0; - let value = record.token(3)?.as_integer()?; - let color_value = if (1..=255).contains(&value) { - Some(acadrust::Color::from_index(value as i16)) - } else if value > 257 { - Some(acadrust::Color::from_true_color_value(value as i32)) - } else { - None - }?; - let mut rgba = - crate::scene::convert::tess_util::aci_to_rgba(&color_value); - rgba[3] = color[3]; - Some((owner, rgba)) - }) - .collect(); - let mut triangle_material_handles: Vec> = Vec::new(); - let mut triangle_colors: Vec> = Vec::new(); - let face_record_indices: Vec = sat - .records - .iter() - .filter(|record| record.is_a("face")) - .map(|record| record.index) - .collect(); - let mut complete = true; - - for (face, face_record_index) in sat.faces().into_iter().zip(face_record_indices) { - let surf_ptr = face.surface(); - let Some(surf_rec) = sat.resolve(surf_ptr) else { - complete = false; - continue; - }; - let before = indices.len(); - match surf_rec.entity_type.as_str() { - "plane-surface" => { - if let Some(plane) = SatPlaneSurface::from_record(surf_rec) { - tess_plane_face( - sat, - &face, - &plane, - lod.chord_frac, - &mut verts, - &mut normals, - &mut indices, - ); - } - } - "cone-surface" => { - if let Some(cone) = SatConeSurface::from_record(surf_rec) { - 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( - sat, - &face, - &sphere, - lod, - &mut verts, - &mut normals, - &mut indices, - ); - } - } - "torus-surface" => { - if let Some(torus) = SatTorusSurface::from_record(surf_rec) { - tess_torus_face( - sat, - &face, - &torus, - lod, - &mut verts, - &mut normals, - &mut indices, - ); - } - } - "spline-surface" | "meshsurf-surface" | "bs3-surface" => { - crate::scene::convert::spline_tess::tess_spline_face( - sat, - &face, - lod, - &mut verts, - &mut normals, - &mut indices, - ); - } - _ => {} - } - if indices.len() == before { - complete = false; - } else { - let material = face_materials.get(&face_record_index).copied(); - triangle_material_handles.extend( - std::iter::repeat(material).take((indices.len() - before) / 3), - ); - let face_color = face_colors.get(&face_record_index).copied(); - triangle_colors.extend( - std::iter::repeat(face_color).take((indices.len() - before) / 3), - ); - } - } - if indices.is_empty() { - return None; - } - Some(( - finalize_mesh( - name, - verts, - normals, - indices, - triangle_material_handles, - triangle_colors, - color, - xform, - ), - complete, - )) -} - -/// Tessellate an ACIS document, preferring the geometry kernel and falling -/// back to the bespoke per-surface sampler when the kernel cannot rebuild the -/// whole shell (a surface kind it does not model, a face whose boundary it -/// cannot express in that surface's own parameters). fn tessellate_acis( sat: &SatDocument, name: String, @@ -279,1120 +10,21 @@ fn tessellate_acis( facet_res: f64, isolines: usize, ) -> Option { - let lifted = crate::scene::convert::acis_kernel::tessellate_sat( - sat, - name.clone(), - color, - facet_res, - ); - let manual = if lifted.as_ref().is_some_and(|set| set.complete) { - None - } else { - tessellate_sat_lods(sat, name.clone(), color, facet_res) - }; - let mut set = match (lifted, manual) { - (Some(set), _) if set.complete => set, - (_, Some(set)) if set.complete => set, - (Some(lifted), Some(manual)) => { - let lifted_tris = lifted - .lods - .first() - .map(|mesh| mesh.indices.len()) - .unwrap_or(0); - let manual_tris = manual - .lods - .first() - .map(|mesh| mesh.indices.len()) - .unwrap_or(0); - if manual_tris > lifted_tris { - manual - } else { - lifted - } - } - (Some(set), None) | (None, Some(set)) => set, - (None, None) => return None, - }; - if set.complete { - 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}]: complete ({tris} tris)"); - } - } else 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}]: partial ({tris} tris); feature/display wires retained" - ); - } - // 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. - attach_feature_edges(&mut set, sat, isolines); - Some(set) + crate::scene::convert::acis_kernel::tessellate_sat(sat, name, color, facet_res, isolines) } -/// Collect the ACIS `edge` records as world-space polyline segments (pairs of -/// endpoints), body-transform them, and store them on the set as the -/// double-single `edge_verts` / `edge_verts_low`. -fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument, isolines: usize) { - let xform = body_transform(sat); - // World-space curved-face generators for the per-frame silhouette pass. - set.curved_gens = collect_curved_gens(sat, xform); - let mut seg_pts = collect_feature_edges(sat); - // ISOLINES ride the same line list and the same body transform as the - // feature edges, so they inherit the offset / per-INSTANCE re-split for free. - seg_pts.extend(collect_isolines(sat, isolines)); - set.edge_verts.reserve(seg_pts.len()); - set.edge_verts_low.reserve(seg_pts.len()); - for p in seg_pts { - let (mut x, mut y, mut z) = (p[0], p[1], p[2]); - if let Some((m, tr, scale)) = xform { - let (lx, ly, lz) = (x, y, z); - x = scale * (lx * m[0] + ly * m[3] + lz * m[6]) + tr[0]; - y = scale * (lx * m[1] + ly * m[4] + lz * m[7]) + tr[1]; - z = scale * (lx * m[2] + ly * m[5] + lz * m[8]) + tr[2]; - } - 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, - ]); - } -} - -/// ISOLINES line-list endpoints (pairs) for the curved faces of the solid: -/// `count` longitudinal lines spaced across each cone/cylinder face, from its -/// bottom rim to its top rim. These are view-independent tessellation lines -/// (AutoCAD's ISOLINES), so a cylinder reads as a cylinder from any angle -/// rather than showing only its two rim circles. Points are body-local -/// (pre-transform), matching [`collect_feature_edges`], so the caller applies -/// the body transform uniformly. -/// Body-local geometry of one cone/cylinder face: its frame, radius, cone taper -/// and the height/angular extent recovered the same way `tess_cone_face` does. -/// Shared by the ISOLINES and silhouette-generator collectors. -struct ConeFaceGeom { - center: [f64; 3], - axis: [f64; 3], - u_dir: [f64; 3], - v_dir: [f64; 3], - radius: f64, - tan_a: f64, - h_min: f64, - h_max: f64, - theta_min: f64, - theta_span: f64, - full: bool, -} - -fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option { - let surf_rec = sat.resolve(face.surface())?; - if surf_rec.entity_type != "cone-surface" { - return None; - } - let cone = SatConeSurface::from_record(surf_rec)?; - let (cx, cy, cz) = cone.center(); - let (ax, ay, az) = cone.axis(); - let (ux, uy, uz) = cone.major_axis(); - let radius = cone_radius(&cone); - let sin_a = cone.sin_half_angle(); - let cos_a = cone.cos_half_angle(); - let axis = norm3([ax, ay, az]); - let u_dir = norm3([ux, uy, uz]); - let v_dir = cross3(axis, u_dir); - - let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); - 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, face, &cone, axis, [cx, cy, cz]) { - h_min = vmin; - h_max = vmax; - if full { - theta_min = 0.0; - theta_max = TAU; - } - } - } - let theta_span = if full { TAU } else { theta_max - theta_min }; - 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 - }; - Some(ConeFaceGeom { - center: [cx, cy, cz], - axis, - u_dir, - v_dir, - radius, - tan_a, - h_min, - h_max, - theta_min, - theta_span, - full, - }) -} - -/// Pick `count` parameter values across `[t_min, t_min + span]`. A closed -/// revolution (`full`) is divided into `count` values around the full turn (the -/// line at `t` and `t + span` coincide); a bounded arc gets `count` interior -/// values, its two ends already drawn as rim edges. -fn iso_params(t_min: f64, span: f64, full: bool, count: usize) -> Vec { - (0..count) - .map(|k| { - if full { - t_min + span * (k as f64 / count as f64) - } else { - t_min + span * ((k as f64 + 1.0) / (count as f64 + 1.0)) - } - }) - .collect() -} - -fn collect_isolines(sat: &SatDocument, count: usize) -> Vec<[f64; 3]> { - if count == 0 { - return Vec::new(); - } - let mut out: Vec<[f64; 3]> = Vec::new(); - for face in sat.faces() { - let Some(surf) = sat.resolve(face.surface()) else { - continue; - }; - match surf.entity_type.as_str() { - "cone-surface" => cone_isolines(sat, &face, count, &mut out), - "sphere-surface" => sphere_isolines(sat, &face, count, &mut out), - "torus-surface" => torus_isolines(sat, &face, count, &mut out), - _ => {} - } - } - out -} - -/// Longitudinal lines up a cone/cylinder face, bottom rim to top rim. -fn cone_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) { - let Some(g) = cone_face_geom(sat, face) else { - return; - }; - 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, - )); - } -} - -/// Meridian lines on a sphere face — the standard "how a sphere reads" isolines, -/// each running pole-ward across the face's colatitude span at `count` evenly -/// spaced longitudes within the face's own longitude span. -fn sphere_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) { - let Some(surf) = sat.resolve(face.surface()) else { - return; - }; - let Some(sphere) = SatSphereSurface::from_record(surf) else { - return; - }; - let (cx, cy, cz) = sphere.center(); - let r = sphere.radius(); - let SphereWindow { - pole, - u, - v, - theta_min, - theta_span, - theta_full: full, - phi_min, - phi_max, - } = sphere_window(sat, face, &sphere); - // Meridian subdivisions from the sphere radius and colatitude span (a great - // circle of radius `r`) at the shared edge chord tolerance. - let m = edge_arc_segs(r, phi_max - phi_min); - let sphere_pt = |theta: f64, phi: f64| { - let d = sphere_dir(pole, u, v, theta, phi); - [cx + r * d[0], cy + r * d[1], cz + r * d[2]] - }; - for theta in iso_params(theta_min, theta_span, full, count) { - for k in 0..m { - let p0 = phi_min + (phi_max - phi_min) * (k as f64 / m as f64); - let p1 = phi_min + (phi_max - phi_min) * ((k + 1) as f64 / m as f64); - out.push(sphere_pt(theta, p0)); - out.push(sphere_pt(theta, p1)); - } - } -} - -/// Minor (cross-section) circles on a torus face at `count` revolution angles -/// spanning the face — how a torus tube reads. -fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec<[f64; 3]>) { - let Some(surf) = sat.resolve(face.surface()) else { - return; - }; - let Some(torus) = SatTorusSurface::from_record(surf) else { - return; - }; - let (cx, cy, cz) = torus.center(); - let axis = norm3([torus.normal().0, torus.normal().1, torus.normal().2]); - let u = norm3([ - torus.u_direction().0, - torus.u_direction().1, - torus.u_direction().2, - ]); - let v = cross3(axis, u); - let major = torus.major_radius(); - let minor = torus.minor_radius(); - let (phi_min, phi_span, full) = - torus_phi_range(sat, face, [cx, cy, cz], u, v, major); - let phi_total = if full { TAU } else { phi_span }; - let (theta_min, theta_span, theta_full) = - torus_theta_range(sat, face, [cx, cy, cz], axis, major, minor); - let theta_total = if theta_full { TAU } else { theta_span }; - - // Minor (cross-section) arcs — constant revolution angle, clipped to the - // face's tube-angle window. - // Segment count from the tube (minor) radius at the shared edge tolerance. - let m = edge_arc_segs(minor, theta_total); - for phi in iso_params(phi_min, phi_span, full, count) { - for t in 0..m { - let t0 = theta_min + theta_total * (t as f64 / m as f64); - let t1 = theta_min + theta_total * ((t + 1) as f64 / m as f64); - out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t0, phi)); - out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi)); - } - } - - // 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. - let ring_segs = edge_arc_segs(major, phi_total).max(2); - let n_ring = count.max(2); - for theta in iso_params(theta_min, theta_span, theta_full, n_ring) { - 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)); - } - } -} - -/// Longitude/colatitude span of a sphere face from its boundary polygon. -/// Returns `(theta_min, theta_span, full, phi_min, phi_max)`; an empty boundary -/// (a lone full sphere) spans the whole surface. -fn sphere_param_range( - poly: &[[f64; 3]], - center: [f64; 3], - pole: [f64; 3], - u: [f64; 3], - v: [f64; 3], -) -> (f64, f64, bool, f64, f64) { - use std::f64::consts::PI; - if poly.len() < 2 { - return (0.0, TAU, true, 0.0, PI); - } - let mut thetas: Vec = Vec::new(); - let (mut phi_min, mut phi_max) = (f64::MAX, f64::MIN); - for &p in poly { - let d = norm3([p[0] - center[0], p[1] - center[1], p[2] - center[2]]); - let cphi = dot3(d, pole).clamp(-1.0, 1.0); - let phi = cphi.acos(); - phi_min = phi_min.min(phi); - phi_max = phi_max.max(phi); - thetas.push(dot3(d, v).atan2(dot3(d, u))); - } - 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), - ) -} - -/// Frame and parameter window a sphere face is drawn in. -/// -/// The surface record's pole is only a parametrisation choice, so a dish cap is -/// commonly bounded by a circle that is no line of latitude in that frame. A -/// θ/φ bounding box of such a boundary covers nearly the whole ball, drawing the -/// cap as a complete sphere. When the face's loops are circles lying on the -/// sphere we re-pole the frame onto their common axis, where the seam *is* a -/// latitude and the window is exact. -struct SphereWindow { - pole: [f64; 3], - u: [f64; 3], - v: [f64; 3], - theta_min: f64, - theta_span: f64, - /// Longitude wrap only. A cap centred on the pole covers every longitude - /// while still ending at its seam, so this never widens `phi_min/phi_max`. - theta_full: bool, - phi_min: f64, - phi_max: f64, -} - -/// Resolve the frame and trim window for a sphere face, preferring the analytic -/// cap/zone derivation and falling back to the boundary-polygon bounding box. -fn sphere_window(sat: &SatDocument, face: &SatFace, sphere: &SatSphereSurface) -> SphereWindow { - let (cx, cy, cz) = sphere.center(); - let center = [cx, cy, cz]; - let radius = sphere.radius(); - let acis_pole = norm3([sphere.pole().0, sphere.pole().1, sphere.pole().2]); - let acis_u = norm3([ - sphere.u_direction().0, - sphere.u_direction().1, - sphere.u_direction().2, - ]); - let acis_v = cross3(acis_pole, acis_u); - - if let Some((pole, phi_min, phi_max)) = sphere_cap_window(sat, face, center, radius) { - let u = perp_axis(pole); - return SphereWindow { - pole, - u, - v: cross3(pole, u), - // A face closed by full circles of latitude wraps every longitude. - theta_min: 0.0, - theta_span: TAU, - theta_full: true, - phi_min, - phi_max, - }; - } - - let poly = collect_face_polygon(sat, face, BOUNDARY_CHORD_FRAC); - let (theta_min, theta_span, full, phi_min, phi_max) = - sphere_param_range(&poly, center, acis_pole, acis_u, acis_v); - // `full` reports the *longitude* wrap only. A cap sitting on the pole covers - // every longitude while still spanning a narrow colatitude band, so the - // derived φ window stands on its own and callers must not widen it back to - // the whole 0..π meridian — that grows the cap into a complete ball. - SphereWindow { - pole: acis_pole, - u: acis_u, - v: acis_v, - theta_min, - theta_span, - theta_full: full, - phi_min, - phi_max, - } -} - -/// Colatitude window of a sphere face bounded by circles that lie on the sphere, -/// in a frame poled on those circles' common axis. `None` when the boundary is -/// not made of such circles, leaving the caller on the polygon fallback. -/// -/// One boundary circle bounds a cap, two bound a zone. The cut plane of a circle -/// at distance `d` from the centre meets the sphere at colatitude `acos(d / R)` -/// measured about the circle's axis, so each seam maps to an exact latitude. -fn sphere_cap_window( +pub(crate) fn body_transform( sat: &SatDocument, - face: &SatFace, - center: [f64; 3], - radius: f64, -) -> Option<([f64; 3], f64, f64)> { - use std::f64::consts::PI; - const GEOM_EPS: f64 = 1e-6; - if !(radius.abs() > GEOM_EPS) { - return None; - } - let r2 = radius * radius; - - // Per boundary circle: its plane's offset from the sphere centre, that - // offset's length, and the plane normal. - let mut rings: Vec<([f64; 3], f64, [f64; 3])> = Vec::new(); - let mut first_loop_seen = false; - let mut sense_axis: Option<[f64; 3]> = None; - - let first = face.first_loop(); - let mut current = first; - let mut seen: HashSet = HashSet::default(); - while !current.is_null() && seen.insert(current.0) { - let sat_loop = SatLoop::from_record(sat.resolve(current)?)?; - let (cc, normal, ring_radius) = loop_circle_geometry(sat, &sat_loop)?; - let offset = [cc[0] - center[0], cc[1] - center[1], cc[2] - center[2]]; - let dist2 = dot3(offset, offset); - // The circle has to be a section of this sphere: d² + r_ring² == R². - if (dist2 + ring_radius * ring_radius - r2).abs() > 1e-6 * r2.max(1.0) { - return None; - } - let dist = dist2.sqrt(); - let axis = if dist > GEOM_EPS { - norm3(offset) - } else { - // A great circle is centred on the sphere, so only its plane gives an - // axis; which side of it the face keeps has to come from the winding. - normal - }; - if !first_loop_seen { - first_loop_seen = true; - // The in-surface direction pointing into the face tells us which of - // the two caps this plane cuts is the material one. - if let Some((_, inward)) = circle_loop_inward_direction(sat, face, &sat_loop) { - sense_axis = Some(if dot3(inward, axis) >= 0.0 { - axis - } else { - [-axis[0], -axis[1], -axis[2]] - }); - } else if dist > GEOM_EPS { - // No usable winding: keep the cap the offset points at, which is - // the shallow dish that an offset seam normally bounds. - sense_axis = Some(axis); - } - } - rings.push((offset, dist, normal)); - current = sat_loop.next_loop(); - if current == first { - break; - } - } - - let pole = sense_axis?; - match rings.len() { - 1 => { - let (_, dist, _) = rings[0]; - let phi_seam = (dist / radius).clamp(-1.0, 1.0).acos(); - Some((pole, 0.0, phi_seam)) - } - 2 => { - // Both seams must lie in planes square to the shared axis, otherwise - // this is not a zone and the box fallback is the honest answer. An - // offset ring proves that by having its offset run along the axis; a - // ring centred on the sphere has no offset to test, so its own plane - // normal has to line up instead. - for (offset, dist, normal) in &rings { - let square = if *dist > GEOM_EPS { - (dot3(*offset, pole).abs() - *dist).abs() <= 1e-6 * dist.max(1.0) - } else { - (dot3(*normal, pole).abs() - 1.0).abs() <= 1e-6 - }; - if !square { - return None; - } - } - // acos maps into [0, π] already, so these are ordered colatitudes. - let mut phis = [0.0f64; 2]; - for (i, (offset, _, _)) in rings.iter().enumerate() { - phis[i] = (dot3(*offset, pole) / radius).clamp(-1.0, 1.0).acos(); - } - let (lo, hi) = if phis[0] <= phis[1] { - (phis[0], phis[1]) - } else { - (phis[1], phis[0]) - }; - debug_assert!((0.0..=PI).contains(&lo) && (0.0..=PI).contains(&hi)); - Some((pole, lo, hi)) - } - _ => None, - } -} - -/// Reference cross-section radius of a cone/cylinder surface. -/// -/// The record's trailing real is a parameter scale, not geometry — it coincides -/// with the radius on many bodies but diverges on others, which draws the -/// lateral surface at the wrong size while the rims stay put (a gauge dial -/// rendering as a ring around its own face). The major axis *is* the radius -/// vector at the reference cross-section, so its length is the radius; fall back -/// to the record's field only when that vector is missing. -pub(crate) fn cone_radius(cone: &SatConeSurface) -> f64 { - let (x, y, z) = cone.major_axis(); - let len = (x * x + y * y + z * z).sqrt(); - if len > 1e-12 { - len - } else { - cone.radius() - } -} - -/// Any unit vector square to `axis`, for completing a frame. -fn perp_axis(axis: [f64; 3]) -> [f64; 3] { - let seed = if axis[0].abs() < 0.9 { - [1.0, 0.0, 0.0] - } else { - [0.0, 1.0, 0.0] + body_record: usize, +) -> Result, ()> { + let body = SatBody::from_record(sat.record(body_record).ok_or(())?).ok_or(())?; + let Some(transform_record) = body.transform().index() else { + return Ok(None); }; - norm3(cross3(seed, axis)) -} - -/// Revolution-angle arc a torus face spans, walking all its boundary loops. -/// -/// A partial tube ends in two oriented minor-circle boundary loops sitting in -/// constant-φ planes. Each loop's coedge and edge senses, combined with the -/// face sense, identify the side that belongs to the trimmed face. That makes -/// both short elbows and long open tubes follow their recorded boundary rather -/// than choosing an angular span by size. If that trim topology is absent or -/// ambiguous, the face remains untrimmed. 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], - major: f64, -) -> (f64, f64, bool) { - const GEOM_EPS: f64 = 1e-6; - let axis = norm3(cross3(u, v)); - let mut starts = Vec::new(); - let mut ends = 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; - }; - lp = sl.next_loop(); - let Some((boundary_center, inward_direction)) = - circle_loop_inward_direction(sat, face, &sl) - else { - continue; - }; - let rel = [ - boundary_center[0] - center[0], - boundary_center[1] - center[1], - boundary_center[2] - center[2], - ]; - let axial = dot3(rel, axis); - let radial_u = dot3(rel, u); - let radial_v = dot3(rel, v); - let radial_len = (radial_u * radial_u + radial_v * radial_v).sqrt(); - let scale = major.abs().max(radial_len).max(1.0); - if axial.abs() > scale * GEOM_EPS - || (radial_len - major.abs()).abs() > scale * GEOM_EPS - { - continue; - } - let physical_phi = radial_v.atan2(radial_u); - let phi = if major < 0.0 { - (physical_phi - std::f64::consts::PI).rem_euclid(TAU) - } else { - physical_phi.rem_euclid(TAU) - }; - let major_sign = if major < 0.0 { -1.0 } else { 1.0 }; - let tangent = norm3([ - major_sign * (-u[0] * phi.sin() + v[0] * phi.cos()), - major_sign * (-u[1] * phi.sin() + v[1] * phi.cos()), - major_sign * (-u[2] * phi.sin() + v[2] * phi.cos()), - ]); - let alignment = dot3(tangent, inward_direction); - if (alignment.abs() - 1.0).abs() > GEOM_EPS { - continue; - } - if alignment > 0.0 { - starts.push(phi); - } else { - ends.push(phi); - } + let transform = sat.record(transform_record).ok_or(())?; + if transform.entity_type != "transform" { + return Err(()); } - - starts.sort_by(|left, right| left.partial_cmp(right).unwrap()); - ends.sort_by(|left, right| left.partial_cmp(right).unwrap()); - starts.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); - ends.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); - if let ([start], [end]) = (starts.as_slice(), ends.as_slice()) { - let span = (end - start).rem_euclid(TAU); - if span > GEOM_EPS && span < TAU - GEOM_EPS { - return (*start, span, false); - } - } - (0.0, TAU, true) -} - -/// Analytic circle data carried by an ellipse edge in a boundary loop. -fn loop_circle_geometry( - sat: &SatDocument, - sat_loop: &SatLoop, -) -> Option<([f64; 3], [f64; 3], f64)> { - let first = sat_loop.first_coedge(); - let mut current = first; - let mut seen: HashSet = HashSet::default(); - while !current.is_null() && seen.insert(current.0) { - let coedge = SatCoedge::from_record(sat.resolve(current)?)?; - if let Some(ellipse) = sat - .resolve(coedge.edge()) - .and_then(SatEdge::from_record) - .and_then(|edge| sat.resolve(edge.curve())) - .and_then(SatEllipseCurve::from_record) - { - if (ellipse.ratio() - 1.0).abs() <= 1e-6 { - let (cx, cy, cz) = ellipse.center(); - let (nx, ny, nz) = ellipse.normal(); - let (mx, my, mz) = ellipse.major_axis(); - let radius = (mx * mx + my * my + mz * mz).sqrt(); - return Some(([cx, cy, cz], norm3([nx, ny, nz]), radius)); - } - } - current = coedge.next(); - if current == first { - break; - } - } - None -} - -/// Circle centre and the in-surface direction lying inside an oriented loop. -/// The direction comes directly from the boundary curve winding and face sense. -fn circle_loop_inward_direction( - sat: &SatDocument, - face: &SatFace, - sat_loop: &SatLoop, -) -> Option<([f64; 3], [f64; 3])> { - const GEOM_EPS: f64 = 1e-6; - let first = sat_loop.first_coedge(); - let mut current = first; - let mut seen: HashSet = HashSet::default(); - while !current.is_null() && seen.insert(current.0) { - let coedge = SatCoedge::from_record(sat.resolve(current)?)?; - let Some(edge) = sat.resolve(coedge.edge()).and_then(SatEdge::from_record) else { - break; - }; - let Some(ellipse) = sat - .resolve(edge.curve()) - .and_then(SatEllipseCurve::from_record) - else { - current = coedge.next(); - if current == first { - break; - } - continue; - }; - if (ellipse.ratio() - 1.0).abs() > GEOM_EPS { - current = coedge.next(); - if current == first { - break; - } - continue; - } - - let center = ellipse.center(); - let ellipse_frame = ellipse_frame(&ellipse, matches!(edge.sense(), Sense::Reversed))?; - let coedge_forward = matches!(coedge.sense(), Sense::Forward); - let parameter = if coedge_forward { - edge.start_param() - } else { - edge.end_param() - }; - let edge_span = edge.end_param() - edge.start_param(); - let edge_direction = if edge_span < -GEOM_EPS { -1.0 } else { 1.0 }; - let traversal_direction = if coedge_forward { - edge_direction - } else { - -edge_direction - }; - let (point, curve_tangent) = ellipse_frame.point_tangent(parameter); - let boundary_tangent = norm3([ - traversal_direction * curve_tangent[0], - traversal_direction * curve_tangent[1], - traversal_direction * curve_tangent[2], - ]); - let face_sign = if matches!(face.sense(), Sense::Reversed) { - -1.0 - } else { - 1.0 - }; - let face_normal = norm3([ - face_sign * (point[0] - center.0), - face_sign * (point[1] - center.1), - face_sign * (point[2] - center.2), - ]); - let inward = norm3(cross3(face_normal, boundary_tangent)); - return Some(([center.0, center.1, center.2], inward)); - } - None -} - -/// Tube-angle span of a torus face from its boundary loops. -/// -/// Ring tori normally cover the complete minor circle, while a spindle-torus -/// cap terminates at the surface's axial singularity and owns just one -/// constant-θ boundary ring. Sampling the complete minor circle in that case -/// duplicates the cap across the rest of the self-intersecting analytic -/// surface, producing a ball much larger than the bounded face. -fn torus_theta_range( - sat: &SatDocument, - face: &SatFace, - center: [f64; 3], - axis: [f64; 3], - major: f64, - minor: f64, -) -> (f64, f64, bool) { - const GEOM_EPS: f64 = 1e-6; - - if minor <= 1e-9 || major.abs() >= minor { - return (0.0, TAU, true); - } - - let mut rims = Vec::new(); - let mut lp = face.first_loop(); - let mut seen: HashSet = HashSet::default(); - while !lp.is_null() && seen.insert(lp.0) { - let Some(sat_loop) = sat.resolve(lp).and_then(SatLoop::from_record) else { - break; - }; - lp = sat_loop.next_loop(); - let Some((circle_center, circle_normal, circle_radius)) = - loop_circle_geometry(sat, &sat_loop) - else { - continue; - }; - let rel = [ - circle_center[0] - center[0], - circle_center[1] - center[1], - circle_center[2] - center[2], - ]; - let axial = dot3(rel, axis); - let planar = [ - rel[0] - axial * axis[0], - rel[1] - axial * axis[1], - rel[2] - axial * axis[2], - ]; - let scale = major.abs().max(minor).max(circle_radius).max(1.0); - if dot3(planar, planar).sqrt() > scale * GEOM_EPS - || (dot3(circle_normal, axis).abs() - 1.0).abs() > GEOM_EPS - { - continue; - } - - // The principal spindle branch has non-negative revolution radius: - // circle_radius = major + minor·cos(θ). Together with the circle - // plane's axial coordinate this recovers θ directly. - let sin_theta = axial / minor; - let cos_theta = (circle_radius - major) / minor; - if (sin_theta * sin_theta + cos_theta * cos_theta - 1.0).abs() > GEOM_EPS { - continue; - } - rims.push(sin_theta.atan2(cos_theta)); - } - - rims.sort_by(|left, right| left.partial_cmp(right).unwrap()); - rims.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); - if rims.len() != 1 || rims[0].abs() < GEOM_EPS { - return (0.0, TAU, true); - } - - let rim = rims[0]; - let singular = (-major / minor).clamp(-1.0, 1.0).acos(); - if rim > 0.0 && rim < singular { - (rim, singular - rim, false) - } else if rim < 0.0 && rim > -singular { - (-singular, singular + rim, false) - } else { - (0.0, TAU, true) - } -} - -/// Reduce a set of angles to a `(min, span, full)` arc. Mirrors `angular_range`'s -/// gap detection: the largest gap between sorted angles is the arc's *outside*, -/// so the arc runs from the gap's end round to its start; a small largest gap -/// means the angles wrap the whole circle. -fn angular_span(angles: &[f64]) -> (f64, f64, bool) { - if angles.is_empty() { - return (0.0, TAU, true); - } - let mut a: Vec = angles.iter().map(|x| x.rem_euclid(TAU)).collect(); - a.sort_by(|x, y| x.partial_cmp(y).unwrap()); - 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 gap = next - a[i]; - if gap > gap_max { - gap_max = gap; - gap_at = i; - } - } - if gap_max < TAU / 12.0 { - return (0.0, TAU, true); // wraps the full circle - } - let start = a[(gap_at + 1) % a.len()]; - (start, TAU - gap_max, false) -} - -/// World-space silhouette generators for each cone/cylinder face — the params a -/// per-frame DISPSILH pass needs. `xform` is the solid's body transform (or -/// `None`); directions are rotated by it, the base point is placed by it and -/// split into the double-single pair. -fn collect_curved_gens( - sat: &SatDocument, - xform: Option<([f64; 9], [f64; 3], f64)>, -) -> Vec { - use crate::scene::model::mesh_model::CurvedGen; - let rot_dir = |d: [f64; 3]| -> [f32; 3] { - let w = match xform { - Some((m, _, _)) => norm3([ - d[0] * m[0] + d[1] * m[3] + d[2] * m[6], - d[0] * m[1] + d[1] * m[4] + d[2] * m[7], - d[0] * m[2] + d[1] * m[5] + d[2] * m[8], - ]), - None => d, - }; - [w[0] as f32, w[1] as f32, w[2] as f32] - }; - let scale = xform.map(|(_, _, s)| s).unwrap_or(1.0); - // Place a world point and split it into the double-single (high, low) pair. - let place = |p: [f64; 3]| -> ([f32; 3], [f32; 3]) { - let (wx, wy, wz) = match xform { - Some((m, tr, s)) => ( - s * (p[0] * m[0] + p[1] * m[3] + p[2] * m[6]) + tr[0], - s * (p[0] * m[1] + p[1] * m[4] + p[2] * m[7]) + tr[1], - s * (p[0] * m[2] + p[1] * m[5] + p[2] * m[8]) + tr[2], - ), - None => (p[0], p[1], p[2]), - }; - 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, - ], - ) - }; - let mut out = Vec::new(); - for face in sat.faces() { - let Some(surf) = sat.resolve(face.surface()) else { - continue; - }; - match surf.entity_type.as_str() { - "cone-surface" => { - 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], - g.center[2] + g.h_min * g.axis[2], - ]; - let (base, base_low) = place(base_local); - out.push(CurvedGen::Cone { - base, - base_low, - axis: rot_dir(g.axis), - u_dir: rot_dir(g.u_dir), - v_dir: rot_dir(g.v_dir), - // `radius` is the cone radius at `base`, which sits at h_min — - // NOT at the surface's h=0 root. `cone_face_geom.radius` is the - // root radius, so add the h_min offset (`radius + h_min·tan_a`) - // or the silhouette's `r0 = radius` lands at the wrong radius - // and its top `r1 = radius + span·tan_a` overshoots the apex. - radius: ((g.radius + g.h_min * g.tan_a) * scale) as f32, - tan_a: g.tan_a as f32, - h_max: ((g.h_max - g.h_min) * scale) as f32, - theta_min: g.theta_min as f32, - theta_span: g.theta_span as f32, - full: g.full, - }); - } - "sphere-surface" => { - let Some(sphere) = SatSphereSurface::from_record(surf) else { - continue; - }; - let (cx, cy, cz) = sphere.center(); - let SphereWindow { - pole, - u, - v, - theta_min: tmin, - theta_span: tspan, - theta_full: full, - phi_min: pmin, - phi_max: pmax, - } = sphere_window(sat, &face, &sphere); - let (center, center_low) = place([cx, cy, cz]); - out.push(CurvedGen::Sphere { - center, - center_low, - pole: rot_dir(pole), - u_dir: rot_dir(u), - v_dir: rot_dir(v), - radius: (sphere.radius() * scale) as f32, - theta_min: tmin as f32, - theta_span: tspan as f32, - full, - phi_min: pmin as f32, - phi_max: pmax as f32, - }); - } - "torus-surface" => { - 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([ - torus.u_direction().0, - torus.u_direction().1, - torus.u_direction().2, - ]); - let v = cross3(axis, u); - let (pmin, pspan, full) = torus_phi_range( - sat, - &face, - [cx, cy, cz], - u, - v, - torus.major_radius(), - ); - let (tmin, tspan, tfull) = torus_theta_range( - sat, - &face, - [cx, cy, cz], - axis, - torus.major_radius(), - torus.minor_radius(), - ); - let (center, center_low) = place([cx, cy, cz]); - out.push(CurvedGen::Torus { - center, - center_low, - axis: rot_dir(axis), - u_dir: rot_dir(u), - v_dir: rot_dir(v), - major: (torus.major_radius() * scale) as f32, - minor: (torus.minor_radius() * scale) as f32, - phi_min: pmin as f32, - phi_span: pspan as f32, - full, - theta_min: tmin as f32, - theta_span: tspan as f32, - theta_full: tfull, - }); - } - _ => {} - } - } - out -} - -/// Line-list endpoints (pairs) for every `edge` record: straight edges emit -/// their two vertex endpoints; ellipse/circle edges are sampled along their -/// bounded parametric arc. Points are in body-local space (pre-transform). -fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> { - let mut out: Vec<[f64; 3]> = Vec::new(); - for er in sat.records_of_type("edge") { - let Some(edge) = SatEdge::from_record(er) else { - continue; - }; - // Ordered points along the edge (≥2). - let mut pts: Vec<[f64; 3]> = Vec::new(); - if let Some(cr) = sat.resolve(edge.curve()) { - if let Some(ellipse) = SatEllipseCurve::from_record(cr) { - let reversed = matches!(edge.sense(), Sense::Reversed); - pts = sample_ellipse_arc( - &ellipse, - edge.start_param(), - edge.end_param(), - EDGE_CHORD_FRAC, - reversed, - ); - // sample_ellipse_arc drops the end param; append the true end so - // the polyline closes onto the shared vertex. - if let Some(p) = vertex_point(sat, edge.end_vertex()) { - pts.push(p); - } - } else if let Some(ic) = SatIntCurve::from_record(cr) { - // Spline edge — sample the actual curve instead of the straight - // chord the fallback below would draw (or nothing, for a closed - // loop whose endpoints coincide). - pts = ic - .sample_range( - edge.start_param(), - edge.end_param(), - nominal_segs(EDGE_CHORD_FRAC), - ) - .into_iter() - .map(|(x, y, z)| [x, y, z]) - .collect(); - } - } - if pts.len() < 2 { - // Straight edge (or unsampled curve): connect the two vertices. - pts.clear(); - if let (Some(a), Some(b)) = ( - vertex_point(sat, edge.start_vertex()), - vertex_point(sat, edge.end_vertex()), - ) { - pts.push(a); - pts.push(b); - } - } - // Emit consecutive points as line-list segment pairs. - for w in pts.windows(2) { - out.push(w[0]); - out.push(w[1]); - } - } - out -} - -/// Resolve a vertex pointer to its point coordinates. -fn vertex_point(sat: &SatDocument, vptr: SatPointer) -> Option<[f64; 3]> { - let vrec = sat.resolve(vptr)?; - let vertex = SatVertex::from_record(vrec)?; - let prec = sat.resolve(vertex.point())?; - let point = SatPoint::from_record(prec)?; - let (x, y, z) = point.position(); - Some([x, y, z]) -} - -/// Tessellate a SAT document at all three LODs and bundle them into a -/// `MeshLodSet` ready for the render pipeline to pick a level per frame. -fn tessellate_sat_lods( - sat: &SatDocument, - name: String, - color: [f32; 4], - facet_res: f64, -) -> Option { - let configs = LodConfig::all(); - let xform = body_transform(sat); - let mut lods: Vec = Vec::with_capacity(3); - let mut complete = true; - for lod in configs { - let scaled = scale_lod(lod, facet_res); - if let Some((m, lod_complete)) = tessellate_sat(sat, name.clone(), color, scaled, xform) { - lods.push(m); - complete &= lod_complete; - } else { - complete = false; - } - } - if lods.is_empty() { - return None; - } - let mut set = MeshLodSet::from_lods(lods); - set.complete = complete; - Some(set) -} - -/// Extract the body's placement transform from the SAT document: a row-major -/// 3×3 affine, a translation, and the uniform scale. ACIS keeps a solid's -/// geometry in body-local space and records the placement in a `transform` -/// record (`<3×3> rotate reflect shear`). `None` when the -/// document has no transform (treated as identity). -pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f64)> { - let transform = sat - .records - .iter() - .find(|record| record.entity_type == "transform")?; let mut values = Vec::with_capacity(13); for token in &transform.tokens { if values.len() >= 13 { @@ -1403,8 +35,6 @@ pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f } else if let Some(value) = token.as_float() { values.push(value); } else if let Some(text) = token.as_string() { - // Some ASM SAB bodies pack the complete SAT transform payload in - // one long-string token instead of individual numeric tokens. for word in text.split_ascii_whitespace() { let Ok(value) = word.parse::() else { break; @@ -1416,31 +46,23 @@ pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f } } } - (values.len() >= 13).then(|| { - ( - [ - values[0], values[1], values[2], values[3], values[4], values[5], values[6], - values[7], values[8], - ], - [values[9], values[10], values[11]], - values[12], - ) - }) + (values.len() >= 13 + && values[..13].iter().all(|value| value.is_finite()) + && values[12] > 0.0) + .then(|| { + ( + [ + values[0], values[1], values[2], values[3], values[4], values[5], values[6], + values[7], values[8], + ], + [values[9], values[10], values[11]], + values[12], + ) + }) + .map(Some) + .ok_or(()) } -/// Apply a body placement transform to a mesh. ACIS treats points as row -/// vectors (`p' = scale·(p·M) + T`), so the 3×3 is indexed transposed relative -/// to a column-vector multiply. Normals get the rotation only, renormalized. -/// Build a `MeshModel` from f64 accumulation buffers. This is the ONLY place a -/// solid mesh vertex becomes f32: the world coordinate is computed in f64 (the -/// body placement applied, or identity when the solid stores absolute geometry) -/// and split into the double-single (high, low) pair the mesh shader -/// reconstructs relative to the eye — exactly the treatment the feature edges -/// get in `attach_feature_edges`. Casting to f32 any earlier quantizes a solid -/// placed at UTM scale to a ~0.06 m grid, so its shaded faces crawl against -/// their own (double-single) wireframe as the camera moves. The split runs -/// unconditionally: many solids store their geometry in absolute coordinates -/// with no body transform, and those need it just as much as placed ones. pub(crate) fn finalize_mesh( name: String, verts: Vec<[f64; 3]>, @@ -1451,38 +73,47 @@ pub(crate) fn finalize_mesh( color: [f32; 4], xform: Option<([f64; 9], [f64; 3], f64)>, ) -> MeshModel { - let mut hi: Vec<[f32; 3]> = Vec::with_capacity(verts.len()); - let mut lo: Vec<[f32; 3]> = Vec::with_capacity(verts.len()); + let mut verts_high = Vec::with_capacity(verts.len()); + let mut verts_low = Vec::with_capacity(verts.len()); for [x, y, z] in verts { - let (wx, wy, wz) = match &xform { - Some((m, tr, scale)) => ( - scale * (x * m[0] + y * m[3] + z * m[6]) + tr[0], - scale * (x * m[1] + y * m[4] + z * m[7]) + tr[1], - scale * (x * m[2] + y * m[5] + z * m[8]) + tr[2], - ), - None => (x, y, z), + let [x, y, z] = match xform { + Some((matrix, translation, scale)) => [ + scale * (x * matrix[0] + y * matrix[3] + z * matrix[6]) + translation[0], + scale * (x * matrix[1] + y * matrix[4] + z * matrix[7]) + translation[1], + scale * (x * matrix[2] + y * matrix[5] + z * matrix[8]) + translation[2], + ], + None => [x, y, z], }; - let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32); - hi.push([hx, hy, hz]); - lo.push([ - (wx - hx as f64) as f32, - (wy - hy as f64) as f32, - (wz - hz as f64) as f32, + let high = [x as f32, y as f32, z as f32]; + verts_high.push(high); + verts_low.push([ + (x - high[0] as f64) as f32, + (y - high[1] as f64) as f32, + (z - high[2] as f64) as f32, ]); } - let normals = match &xform { - Some((m, _, _)) => normals + let normals = match xform { + Some((matrix, _, _)) => normals .iter() - .map(|n| { - let (x, y, z) = (n[0] as f64, n[1] as f64, n[2] as f64); - let nx = x * m[0] + y * m[3] + z * m[6]; - let ny = x * m[1] + y * m[4] + z * m[7]; - let nz = x * m[2] + y * m[5] + z * m[8]; - let len = (nx * nx + ny * ny + nz * nz).sqrt(); - if len > 1e-9 { - [(nx / len) as f32, (ny / len) as f32, (nz / len) as f32] + .map(|normal| { + let [x, y, z] = [normal[0] as f64, normal[1] as f64, normal[2] as f64]; + let transformed = [ + x * matrix[0] + y * matrix[3] + z * matrix[6], + x * matrix[1] + y * matrix[4] + z * matrix[7], + x * matrix[2] + y * matrix[5] + z * matrix[8], + ]; + let length = (transformed[0] * transformed[0] + + transformed[1] * transformed[1] + + transformed[2] * transformed[2]) + .sqrt(); + if length > 1e-9 { + [ + (transformed[0] / length) as f32, + (transformed[1] / length) as f32, + (transformed[2] / length) as f32, + ] } else { - *n + *normal } }) .collect(), @@ -1490,8 +121,8 @@ pub(crate) fn finalize_mesh( }; MeshModel { name, - verts: hi, - verts_low: lo, + verts: verts_high, + verts_low, normals, indices, triangle_material_handles, @@ -1501,65 +132,22 @@ pub(crate) fn finalize_mesh( } } -/// Tighten/loosen a LOD's chord tolerance by FACETRES (clamped to the -/// documented [0.01, 10.0] range). A higher FACETRES means a finer mesh, so it -/// *divides* the chord fraction; 1.0 is the unchanged baseline. -fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig { - let m = facet_res.clamp(0.01, 10.0); - LodConfig { - chord_frac: (base.chord_frac / m).clamp(1e-4, 0.5), - } -} - -/// World-XY AABB of the mesh — used by the render-pipeline LOD selector -/// to pick a level based on projected pixel diagonal. -#[allow(dead_code)] // superseded by mesh_model::compute_mesh_aabb (3D); kept for reference -pub(crate) fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] { - let mut min_x = f32::INFINITY; - let mut min_y = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut max_y = f32::NEG_INFINITY; - for &[x, y, _] in &mesh.verts { - 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; - } - } - [min_x, min_y, max_x, max_y] -} - fn parse_acis( sat_fn: impl FnOnce() -> Option, is_binary: bool, sab_data: &[u8], ) -> Option { - if let Some(doc) = sat_fn() { - return Some(doc); - } - if is_binary && !sab_data.is_empty() { - return SabReader::read(sab_data).ok(); - } - None + sat_fn().or_else(|| { + (is_binary && !sab_data.is_empty()) + .then(|| SabReader::read(sab_data).ok()) + .flatten() + }) } fn remap_acis_material_bindings( set: &mut MeshLodSet, acis: &acadrust::entities::AcisData, ) { - if acis.materials.is_empty() { - return; - } for lod in &mut set.lods { for handle in lod.triangle_material_handles.iter_mut().flatten() { let reference = handle.value() as i32; @@ -1574,56 +162,19 @@ fn remap_acis_material_bindings( } } -fn attach_stored_silhouettes( - set: &mut MeshLodSet, - silhouettes: &[acadrust::entities::Silhouette], -) { - use crate::scene::model::mesh_model::StoredSilhouette; - set.stored_silhouettes = silhouettes - .iter() - .filter_map(|silhouette| { - let mut edge_verts = Vec::new(); - let mut edge_verts_low = Vec::new(); - for wire in &silhouette.wires { - for segment in wire.points.windows(2) { - for point in segment { - let point = crate::entities::solid3d::wire_point(wire, point); - let high = [point.x as f32, point.y as f32, point.z as f32]; - edge_verts.push(high); - edge_verts_low.push([ - (point.x - high[0] as f64) as f32, - (point.y - high[1] as f64) as f32, - (point.z - high[2] as f64) as f32, - ]); - } - } - } - (!edge_verts.is_empty()).then_some(StoredSilhouette { - viewport_id: silhouette.viewport_id, - view_direction: [ - silhouette.view_direction.x as f32, - silhouette.view_direction.y as f32, - silhouette.view_direction.z as f32, - ], - up_vector: [ - silhouette.up_vector.x as f32, - silhouette.up_vector.y as f32, - silhouette.up_vector.z as f32, - ], - target: [ - silhouette.target.x as f32, - silhouette.target.y as f32, - silhouette.target.z as f32, - ], - is_perspective: silhouette.is_perspective, - edge_verts, - edge_verts_low, - }) - }) - .collect(); +fn finish( + sat: SatDocument, + name: String, + color: [f32; 4], + facet_res: f64, + isolines: usize, + acis: &acadrust::entities::AcisData, +) -> Option { + let mut set = tessellate_acis(&sat, name, color, facet_res, isolines)?; + remap_acis_material_bindings(&mut set, acis); + Some(set) } -/// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels. pub fn tessellate_region( region: &Region, color: [f32; 4], @@ -1635,14 +186,16 @@ pub fn tessellate_region( region.acis_data.is_binary, ®ion.acis_data.sab_data, )?; - let name = region.common.handle.value().to_string(); - let mut set = tessellate_acis(&sat, name, color, facet_res, isolines)?; - remap_acis_material_bindings(&mut set, ®ion.acis_data); - attach_stored_silhouettes(&mut set, ®ion.silhouettes); - Some(set) + finish( + sat, + region.common.handle.value().to_string(), + color, + facet_res, + isolines, + ®ion.acis_data, + ) } -/// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels. pub fn tessellate_body( body: &Body, color: [f32; 4], @@ -1654,17 +207,16 @@ pub fn tessellate_body( body.acis_data.is_binary, &body.acis_data.sab_data, )?; - let name = body.common.handle.value().to_string(); - let mut set = tessellate_acis(&sat, name, color, facet_res, isolines)?; - remap_acis_material_bindings(&mut set, &body.acis_data); - attach_stored_silhouettes(&mut set, &body.silhouettes); - Some(set) + finish( + sat, + body.common.handle.value().to_string(), + color, + facet_res, + isolines, + &body.acis_data, + ) } -/// Tessellate a `Surface` entity (ACAD_SURFACE family) at all three LOD -/// levels. Surfaces are ACIS-backed just like bodies, so the same SAT/SAB -/// path applies — including the B-spline `spline-surface` faces that loft / -/// sweep / revolve produce. pub fn tessellate_surface( surface: &acadrust::entities::Surface, color: [f32; 4], @@ -1676,20 +228,16 @@ pub fn tessellate_surface( surface.acis_data.is_binary, &surface.acis_data.sab_data, )?; - let name = surface.common.handle.value().to_string(); - let surface_isolines = usize::from(surface.u_isolines.max(surface.v_isolines).max(0) as u16); - let mut set = - tessellate_acis(&sat, name, color, facet_res, surface_isolines.max(isolines))?; - remap_acis_material_bindings(&mut set, &surface.acis_data); - attach_stored_silhouettes(&mut set, &surface.silhouettes); - Some(set) + finish( + sat, + surface.common.handle.value().to_string(), + color, + facet_res, + isolines, + &surface.acis_data, + ) } -/// Tessellate a `Solid3D` entity at all three LOD levels. -/// -/// 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], @@ -1701,1000 +249,12 @@ pub fn tessellate_solid3d( solid.acis_data.is_binary, &solid.acis_data.sab_data, )?; - let name = solid.common.handle.value().to_string(); - let mut set = tessellate_acis(&sat, name, color, facet_res, isolines)?; - remap_acis_material_bindings(&mut set, &solid.acis_data); - attach_stored_silhouettes(&mut set, &solid.silhouettes); - Some(set) -} - -// ── Topology helpers ────────────────────────────────────────────────────────── - -/// Walk a face's outer coedge loop and collect ordered 3-D boundary points. -/// -/// Straight edges contribute their start vertex; curved (ellipse / circle) -/// edges are sampled into several points so circular boundaries — e.g. the -/// cap of a cylinder or the rim of a cone — produce a real polygon instead of -/// a single degenerate vertex. `chord_frac` is the chord-height tolerance (as a -/// fraction of each edge's own radius); the segment count per edge derives from -/// it plus that edge's radius and span. -/// -/// 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, - chord_frac: f64, -) -> Vec<[f64; 3]> { - let Some(loop_rec) = sat.resolve(face.first_loop()) else { - return vec![]; - }; - let Some(sat_loop) = SatLoop::from_record(loop_rec) else { - return vec![]; - }; - collect_loop_polygon(sat, &sat_loop, chord_frac) -} - -/// Boundary points of a single coedge loop, in order. -pub(crate) fn collect_loop_polygon( - sat: &SatDocument, - sat_loop: &SatLoop, - chord_frac: f64, -) -> Vec<[f64; 3]> { - let first_ptr = sat_loop.first_coedge(); - let mut cur = first_ptr; - let mut pts: Vec<[f64; 3]> = Vec::new(); - let mut visited: HashSet = HashSet::default(); - - loop { - if cur.is_null() || visited.contains(&cur.0) { - break; - } - visited.insert(cur.0); - - if let Some(ce_rec) = sat.resolve(cur) { - if let Some(coedge) = SatCoedge::from_record(ce_rec) { - append_coedge_points(sat, &coedge, chord_frac, &mut pts); - let next = coedge.next(); - if next == first_ptr { - break; - } - cur = next; - continue; - } - } - break; - } - pts -} - -/// All loops of a face: the outer boundary first, then any inner hole loops. -/// Each loop is returned as an ordered 3-D polygon (≥ 3 points). -pub(crate) fn collect_face_loops( - sat: &SatDocument, - face: &SatFace, - chord_frac: f64, -) -> Vec> { - let mut loops: Vec> = Vec::new(); - let mut loop_ptr = face.first_loop(); - let mut seen: HashSet = HashSet::default(); - while !loop_ptr.is_null() && seen.insert(loop_ptr.0) { - let Some(loop_rec) = sat.resolve(loop_ptr) else { - break; - }; - let Some(sat_loop) = SatLoop::from_record(loop_rec) else { - break; - }; - let poly = collect_loop_polygon(sat, &sat_loop, chord_frac); - if poly.len() >= 3 { - loops.push(poly); - } - loop_ptr = sat_loop.next_loop(); - } - loops -} - -/// Append a coedge's boundary points to `pts`. Ellipse/circle curves are -/// sampled along their parametric arc (excluding the end param so the next -/// coedge's start point provides the junction); all other curve types fall -/// back to the single start vertex, respecting coedge sense. -pub(crate) fn append_coedge_points( - sat: &SatDocument, - coedge: &SatCoedge, - chord_frac: f64, - pts: &mut Vec<[f64; 3]>, -) { - let fwd = matches!(coedge.sense(), Sense::Forward); - let Some(edge_rec) = sat.resolve(coedge.edge()) else { - return; - }; - let Some(edge) = SatEdge::from_record(edge_rec) else { - return; - }; - - if let Some(curve_rec) = sat.resolve(edge.curve()) { - if let Some(ellipse) = SatEllipseCurve::from_record(curve_rec) { - // The edge's own sense (relative to its curve) decides the ellipse - // winding; a reversed edge samples the opposite handedness. - let reversed = matches!(edge.sense(), Sense::Reversed); - // Orient the parameter range before sampling. Reversing an - // endpoint-exclusive sample afterwards would drop the traversal - // start and retain its end, duplicating the next coedge's point and - // cutting the connector corner out of the face boundary. - let (start, end) = if fwd { - (edge.start_param(), edge.end_param()) - } else { - (edge.end_param(), edge.start_param()) - }; - let sampled = sample_ellipse_arc( - &ellipse, - start, - end, - chord_frac, - reversed, - ); - if !sampled.is_empty() { - pts.extend(sampled); - return; - } - } - // Spline (intcurve) edge: sample its own arc so a curved face boundary - // (fillet/blend) is a real loop rather than a straight chord — without - // this the face's parametric extent collapses and it can't be trimmed. - if let Some(ic) = SatIntCurve::from_record(curve_rec) { - let mut sampled: Vec<[f64; 3]> = ic - .sample_range( - edge.start_param(), - edge.end_param(), - nominal_segs(chord_frac), - ) - .into_iter() - .map(|(x, y, z)| [x, y, z]) - .collect(); - // Orient the inclusive sample first, then drop the traversal end so - // the adjacent coedge contributes that shared point. - if !fwd { - sampled.reverse(); - } - sampled.pop(); - if sampled.len() >= 2 { - pts.extend(sampled); - return; - } - } - } - - // Straight / unsupported curve: keep the single start vertex. - let v_ptr = if fwd { - edge.start_vertex() - } else { - edge.end_vertex() - }; - if let Some(pt) = resolve_point(sat, v_ptr) { - pts.push(pt); - } -} - -struct EllipseFrame { - center: [f64; 3], - major: [f64; 3], - minor: [f64; 3], - major_len: f64, -} - -impl EllipseFrame { - fn point_tangent(&self, parameter: f64) -> ([f64; 3], [f64; 3]) { - let (sin_parameter, cos_parameter) = parameter.sin_cos(); - ( - [ - self.center[0] - + self.major[0] * cos_parameter - + self.minor[0] * sin_parameter, - self.center[1] - + self.major[1] * cos_parameter - + self.minor[1] * sin_parameter, - self.center[2] - + self.major[2] * cos_parameter - + self.minor[2] * sin_parameter, - ], - [ - -self.major[0] * sin_parameter + self.minor[0] * cos_parameter, - -self.major[1] * sin_parameter + self.minor[1] * cos_parameter, - -self.major[2] * sin_parameter + self.minor[2] * cos_parameter, - ], - ) - } -} - -/// Analytic ellipse frame shared by boundary sampling and trim orientation. -fn ellipse_frame(ellipse: &SatEllipseCurve, curve_reversed: bool) -> Option { - let center = ellipse.center(); - let major = ellipse.major_axis(); - let major_len = (major.0 * major.0 + major.1 * major.1 + major.2 * major.2).sqrt(); - if major_len < 1e-12 { - return None; - } - let major_u = [ - major.0 / major_len, - major.1 / major_len, - major.2 / major_len, - ]; - let normal = norm3([ellipse.normal().0, ellipse.normal().1, ellipse.normal().2]); - let minor_u = cross3(normal, major_u); - let minor_len = major_len * ellipse.ratio(); - let hand = if curve_reversed { -1.0 } else { 1.0 }; - Some(EllipseFrame { - center: [center.0, center.1, center.2], - major: [major_u[0] * major_len, major_u[1] * major_len, major_u[2] * major_len], - minor: [ - minor_u[0] * minor_len * hand, - minor_u[1] * minor_len * hand, - minor_u[2] * minor_len * hand, - ], - major_len, - }) -} - -/// Sample points along an ellipse/circle arc from `sp` to `ep` (radians). -/// Returns points at the start of each segment (the end param is omitted so -/// adjacent coedges don't double up the shared junction point). Segment count -/// scales with the arc's angular span relative to a full circle. -fn sample_ellipse_arc( - ellipse: &SatEllipseCurve, - sp: f64, - ep: f64, - chord_frac: f64, - curve_reversed: bool, -) -> Vec<[f64; 3]> { - let span = ep - sp; - if span.abs() < 1e-9 { - return vec![]; - } - let Some(frame) = ellipse_frame(ellipse, curve_reversed) else { - return vec![]; - }; - - // Segment count from this ellipse's own radius and arc span at the requested - // chord tolerance — the same model the 2-D circle/arc/ellipse wires use, so - // a big rim samples finer than a small one and a short arc proportionally - // less than a full turn. `major_len` is the reference radius. - let segs = crate::scene::convert::tess_util::arc_segments_floored( - frame.major_len, - span.abs(), - frame.major_len * chord_frac, - 2, - ) as usize; - let mut out = Vec::with_capacity(segs); - for i in 0..segs { - let t = sp + span * (i as f64 / segs as f64); - out.push(frame.point_tangent(t).0); - } - out -} - -/// Resolve a vertex pointer all the way to its `[x, y, z]` coordinate. -pub(crate) fn resolve_point(sat: &SatDocument, v_ptr: SatPointer) -> Option<[f64; 3]> { - let v_rec = sat.resolve(v_ptr)?; - let vertex = SatVertex::from_record(v_rec)?; - let pt_rec = sat.resolve(vertex.point())?; - let point = SatPoint::from_record(pt_rec)?; - let (x, y, z) = point.position(); - Some([x, y, z]) -} - -// ── Mesh builder helpers ────────────────────────────────────────────────────── - -/// Append one quad (two triangles) to the mesh buffers. Vertices stay in f64 -/// world/local space until `finalize_mesh` splits them into the double-single -/// (high, low) pair — casting here would quantize a solid placed at UTM scale to -/// the ~0.06 m f32 grid. -#[inline] -fn push_quad( - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, - p: [[f64; 3]; 4], - n: [f64; 3], -) { - let base = verts.len() as u32; - let nf = [n[0] as f32, n[1] as f32, n[2] as f32]; - for &pt in &p { - verts.push(pt); - normals.push(nf); - } - // Two CCW triangles: (0,1,2) and (0,2,3) - indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); -} - -// ── Planar face ─────────────────────────────────────────────────────────────── - -pub(crate) fn tess_plane_face( - sat: &SatDocument, - face: &SatFace, - plane: &SatPlaneSurface, - chord_frac: f64, - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, -) { - // Every loop, not just the first. A face with a hole in it carries the - // hole on a second loop, and nothing says the outer one is listed first — - // so reading `first_loop` alone drew whichever came first and ignored the - // rest. When that was a hole, the hole came out solid and the material - // around it came out missing. - let rings = collect_face_loops(sat, face, chord_frac); - if rings.iter().all(|ring| ring.len() < 3) { - return; - } - - let (nx, ny, nz) = plane.normal(); - // Flip normal outward if the face sense is reversed. - let (nx, ny, nz) = if matches!(face.sense(), Sense::Reversed) { - (-nx, -ny, -nz) - } else { - (nx, ny, nz) - }; - let nf = [nx as f32, ny as f32, nz as f32]; - - // Flattened into the plane's own frame, where "inside" is a question with - // an answer. The kernel's ear clipping bridges the holes into the outer - // ring; a fan from one vertex cannot express a hole at all. - let (px, py, pz) = plane.root_point(); - let seed = if nx.abs() < 0.9 { - [1.0, 0.0, 0.0] - } else { - [0.0, 1.0, 0.0] - }; - let Some(frame) = - acadrust::kernel::space::Plane::orthonormal([px, py, pz], seed, [nx, ny, nz]) - else { - return; - }; - let flat: Vec> = rings - .iter() - .filter(|ring| ring.len() >= 3) - .map(|ring| ring.iter().filter_map(|p| frame.project(*p)).collect()) - .collect(); - let Some(widest) = flat - .iter() - .enumerate() - .max_by(|a, b| { - acadrust::kernel::geom2d::signed_area(a.1) - .abs() - .total_cmp(&acadrust::kernel::geom2d::signed_area(b.1).abs()) - }) - .map(|(index, _)| index) - else { - return; - }; - let holes: Vec> = flat - .iter() - .enumerate() - .filter(|(index, _)| *index != widest) - .map(|(_, ring)| ring.clone()) - .collect(); - let (points, triangles) = - acadrust::kernel::geom2d::triangulate::polygon(&flat[widest], &holes); - if triangles.is_empty() { - return; - } - - let base = verts.len() as u32; - for point in &points { - verts.push(frame.point_at(*point)); - normals.push(nf); - } - // Ear clipping hands back counter-clockwise triangles in the frame's own - // coordinates; the frame was built around the outward normal, so that is - // already the outward winding. - for triangle in triangles { - indices.extend_from_slice(&[ - base + triangle[0] as u32, - base + triangle[1] as u32, - base + triangle[2] as u32, - ]); - } -} - -// ── Cone / cylinder face ────────────────────────────────────────────────────── - -pub(crate) fn tess_cone_face( - sat: &SatDocument, - face: &SatFace, - cone: &SatConeSurface, - lod: LodConfig, - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, -) { - // Determine the height range and angular span from the boundary. Every loop - // counts: a lateral face closed off by one rim and one pierce curve (a pipe - // branch meeting its run) carries its two ends on *different* loops, so - // reading only the first would size the tube to whichever end came first and - // leave the rest of the leg undrawn. - let poly: Vec<[f64; 3]> = collect_face_loops(sat, face, lod.chord_frac) - .into_iter() - .flatten() - .collect(); - - let (cx, cy, cz) = cone.center(); - let (ax, ay, az) = cone.axis(); // axis direction (unit) - let (ux, uy, uz) = cone.major_axis(); // u=0 direction - let radius = cone_radius(cone); - let sin_a = cone.sin_half_angle(); - let cos_a = cone.cos_half_angle(); // ≈1 for cylinder, <1 for cone - - // Build an orthonormal frame: axis_dir, u_dir, v_dir. - let axis = norm3([ax, ay, az]); - let u_dir = norm3([ux, uy, uz]); - let v_dir = cross3(axis, u_dir); - - // Determine height and angle range from boundary vertices. - let (mut h_min, mut h_max, mut theta_min, mut theta_max, full_circle) = - angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly); - - // A full cylinder/cone face is bounded by a single closed rim, so the - // 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. 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, face, cone, axis, [cx, cy, cz]) { - h_min = vmin; - h_max = vmax; - if full_circle { - theta_min = 0.0; - theta_max = TAU; - } - } - } - - let theta_span = if full_circle { - TAU - } else { - theta_max - theta_min - }; - let h_span = h_max - h_min; - - if h_span.abs() < 1e-10 || theta_span.abs() < 1e-10 { - return; - } - - // Angular divisions from the rim radius and arc span at the LOD's chord - // tolerance — a short boundary arc (a curved wall face) samples proportionally - // less than a whole rim. Use the wider rim so the density bounds chord error - // at both ends. The height direction is a straight generator (a cone/cylinder - // is ruled), so it carries no curvature: one division is geometrically exact. - let r_ref = if cos_a.abs() > 1e-9 { - (radius + h_min * sin_a / cos_a) - .abs() - .max((radius + h_max * sin_a / cos_a).abs()) - } else { - radius.abs() - }; - let segs_u = lod.arc_segs(r_ref, theta_span).max(1); - let segs_v = 1; // straight generator — no curvature along the height - - for j in 0..segs_v { - let t0 = h_min + h_span * (j as f64 / segs_v as f64); - let t1 = h_min + h_span * ((j + 1) as f64 / segs_v as f64); - - for i in 0..segs_u { - let a0 = theta_min + theta_span * (i as f64 / segs_u as f64); - let a1 = theta_min + theta_span * ((i + 1) as f64 / segs_u as f64); - - // Cone radius at height t: r(t) = radius + t * sin_a / cos_a - let r0 = if cos_a.abs() > 1e-9 { - radius + t0 * sin_a / cos_a - } else { - radius - }; - let r1 = if cos_a.abs() > 1e-9 { - radius + t1 * sin_a / cos_a - } else { - radius - }; - - // Wind the quad so its face (CCW) normal points radially outward, - // matching the supplied per-vertex normal `n` below. This keeps - // flat-shaded mode (which derives the normal from winding) and - // Gouraud mode (which uses `n`) consistent. - let p = [ - cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a0, t0), - cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a1, t0), - cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a1, t1), - cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a0, t1), - ]; - - // Outward normal: perpendicular to axis in the radial direction, - // tilted by the cone half-angle. - let mid_a = (a0 + a1) * 0.5; - let rad_dir = [ - u_dir[0] * mid_a.cos() + v_dir[0] * mid_a.sin(), - u_dir[1] * mid_a.cos() + v_dir[1] * mid_a.sin(), - u_dir[2] * mid_a.cos() + v_dir[2] * mid_a.sin(), - ]; - let n = norm3([ - rad_dir[0] * cos_a - axis[0] * sin_a, - rad_dir[1] * cos_a - axis[1] * sin_a, - rad_dir[2] * cos_a - axis[2] * sin_a, - ]); - - push_quad(verts, normals, indices, p, n); - } - } -} - -/// Compute a point on a cone/cylinder surface. -#[inline] -fn cone_pt( - cx: f64, - cy: f64, - cz: f64, - axis: [f64; 3], - u_dir: [f64; 3], - v_dir: [f64; 3], - r: f64, - theta: f64, - h: f64, -) -> [f64; 3] { - [ - cx + r * (u_dir[0] * theta.cos() + v_dir[0] * theta.sin()) + h * axis[0], - cy + r * (u_dir[1] * theta.cos() + v_dir[1] * theta.sin()) + h * axis[1], - cz + r * (u_dir[2] * theta.cos() + v_dir[2] * theta.sin()) + h * axis[2], - ] -} - -/// Determine the height range and angular range of a curved face's boundary. -/// -/// Returns `(h_min, h_max, theta_min, theta_max, full_circle)`. -/// `full_circle` is true when there are no boundary vertices (e.g. a sphere or -/// a cylinder with no seam edge). -fn angular_range( - cx: f64, - cy: f64, - cz: f64, - axis: [f64; 3], - u_dir: [f64; 3], - v_dir: [f64; 3], - poly: &[[f64; 3]], -) -> (f64, f64, f64, f64, bool) { - if poly.is_empty() { - return (0.0, 0.0, 0.0, TAU, true); - } - - let mut h_min = f64::MAX; - let mut h_max = f64::MIN; - let mut angles: Vec = Vec::new(); - - for &pt in poly { - let dx = pt[0] - cx; - let dy = pt[1] - cy; - let dz = pt[2] - cz; - let h = dot3([dx, dy, dz], axis); - h_min = h_min.min(h); - h_max = h_max.max(h); - let rv = dot3([dx, dy, dz], v_dir); - // Project onto the plane perpendicular to the axis. - let ru = dx * u_dir[0] + dy * u_dir[1] + dz * u_dir[2] - - h * (axis[0] * u_dir[0] + axis[1] * u_dir[1] + axis[2] * u_dir[2]); - angles.push(rv.atan2(ru)); - } - - // Find the arc from the LARGEST angular gap, not raw min/max. `atan2` - // returns [-π, π]; a short arc that straddles the ±π seam splits its points - // between ≈+π and ≈-π, so naive `max - min` reads ≈2π and the face balloons - // into a full revolution (a curved wall of radius R drawn as a whole - // R-cylinder). The empty region a face doesn't cover is its largest gap, so - // the real arc is the complement: it starts just after the gap and runs to - // just before it (wrapping past the seam when needed). - angles.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let n = angles.len(); - 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 gap = b - a; - if gap > max_gap { - max_gap = gap; - gap_i = i; - } - } - - // A genuine full circle has points spread all the way round, so its largest - // gap is small. A real arc leaves a wide empty wedge. - let full = max_gap < TAU * 0.05; - - let theta_min = angles[(gap_i + 1) % n]; - let mut theta_max = angles[gap_i]; - if theta_max <= theta_min { - theta_max += TAU; - } - - (h_min, h_max, theta_min, theta_max, full) -} - -/// Recover a cone/cylinder face's height span (along its axis) from the solid's -/// circular rims or B-rep vertices when the face boundary collapses to a -/// single height. -/// -/// 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; - } - let Some(e) = SatEllipseCurve::from_record(rec) else { - continue; - }; - let ec = e.center(); - 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_len = dot3(radial, radial).sqrt(); - let n = e.normal(); - let n_dot = dot3(norm3([n.0, n.1, n.2]), axis).abs(); - 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(cone) + 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 { - for rec in &sat.records { - let Some(point) = SatPoint::from_record(rec) else { - continue; - }; - let position = point.position(); - let d = [ - position.0 - center[0], - position.1 - center[1], - position.2 - center[2], - ]; - let h = dot3(d, axis); - 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 expected = (cone_radius(cone) + h * tangent).abs(); - let tolerance = expected.max(cone_radius(cone).abs()).max(1.0) * 1e-5; - if (radial_len - expected).abs() <= tolerance { - heights.push(h); - } - } - } - if heights.is_empty() { - return None; - } - 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(cone) * 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). - if sin_a.abs() > 1e-6 && heights.len() <= 1 { - let apex = -cone_radius(cone) * cos_a / sin_a; - h_min = h_min.min(apex); - h_max = h_max.max(apex); - } - - if (h_max - h_min).abs() < 1e-9 { - return None; - } - Some((h_min, h_max)) -} - -// ── Sphere face ─────────────────────────────────────────────────────────────── - -pub(crate) fn tess_sphere_face( - sat: &SatDocument, - face: &SatFace, - sphere: &SatSphereSurface, - lod: LodConfig, - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, -) { - let (cx, cy, cz) = sphere.center(); - let r = sphere.radius(); - - // Mesh only the part of the sphere the face covers — its boundary loops' - // longitude/colatitude window. A partial sphere (a dish head, a fillet cap) - // otherwise builds as a full ball floating where the solid is open. - let SphereWindow { - pole, - u: u_dir, - v: v_dir, - theta_min: t_min, - theta_span: t_span, - theta_full: full, - phi_min: p_min, - phi_max: p_max, - } = sphere_window(sat, face, sphere); - let (theta_lo, theta_hi) = if full { - (0.0, TAU) - } else { - (t_min, t_min + t_span) - }; - // φ is trimmed independently: `full` only says the face wraps every - // longitude, which a pole-centred cap does while still ending at its seam. - let (phi_lo, phi_hi) = (p_min, p_max); - - // Longitude / colatitude divisions from the sphere radius and the covered - // spans at the LOD's chord tolerance — a small cap samples far fewer than a - // full ball. Every circle of latitude is at most radius `r` (the equator). - let nu = lod.arc_segs(r, theta_hi - theta_lo).max(1); - let nv = lod.arc_segs(r, phi_hi - phi_lo).max(1); - - for j in 0..nv { - 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 = 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); - let n11 = sphere_dir(pole, u_dir, v_dir, theta1, phi1); - let n01 = sphere_dir(pole, u_dir, v_dir, theta1, phi0); - - let p = [ - [cx + r * n00[0], cy + r * n00[1], cz + r * n00[2]], - [cx + r * n10[0], cy + r * n10[1], cz + r * n10[2]], - [cx + r * n11[0], cy + r * n11[1], cz + r * n11[2]], - [cx + r * n01[0], cy + r * n01[1], cz + r * n01[2]], - ]; - - // Average outward normal for the quad. - let nav = norm3([ - n00[0] + n10[0] + n11[0] + n01[0], - n00[1] + n10[1] + n11[1] + n01[1], - n00[2] + n10[2] + n11[2] + n01[2], - ]); - - push_quad(verts, normals, indices, p, nav); - } - } -} - -#[inline] -fn sphere_dir(pole: [f64; 3], u_dir: [f64; 3], v_dir: [f64; 3], theta: f64, phi: f64) -> [f64; 3] { - let sin_phi = phi.sin(); - let cos_phi = phi.cos(); - let cos_theta = theta.cos(); - let sin_theta = theta.sin(); - // pole × cos_phi + (u*cos_theta + v*sin_theta) × sin_phi - [ - pole[0] * cos_phi + (u_dir[0] * cos_theta + v_dir[0] * sin_theta) * sin_phi, - pole[1] * cos_phi + (u_dir[1] * cos_theta + v_dir[1] * sin_theta) * sin_phi, - pole[2] * cos_phi + (u_dir[2] * cos_theta + v_dir[2] * sin_theta) * sin_phi, - ] -} - -// ── Torus face ──────────────────────────────────────────────────────────────── - -pub(crate) fn tess_torus_face( - sat: &SatDocument, - face: &SatFace, - torus: &SatTorusSurface, - lod: LodConfig, - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, -) { - let (cx, cy, cz) = torus.center(); - let (nx, ny, nz) = torus.normal(); - let axis = norm3([nx, ny, nz]); // revolution axis - let (ux, uy, uz) = torus.u_direction(); - let u_dir = norm3([ux, uy, uz]); - let v_dir = cross3(axis, u_dir); - let major_r = torus.major_radius(); - let minor_r = torus.minor_radius(); - let (theta_start, theta_arc, theta_full) = - torus_theta_range(sat, face, [cx, cy, cz], axis, major_r, minor_r); - let theta_total = if theta_full { TAU } else { theta_arc }; - let nu = if theta_full { - lod.circle_segs(minor_r).max(3) - } else { - lod.arc_segs(minor_r, theta_total).max(1) - }; - - // 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, major_r); - let phi_total = if full { TAU } else { phi_arc }; - // Along-length divisions from the ring (major) radius and the covered arc at - // the LOD's chord tolerance — a short arc samples proportionally less. - let nv = lod.arc_segs(major_r, phi_total).max(2); - - for j in 0..nv { - 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 = theta_start + theta_total * (i as f64 / nu as f64); - let theta1 = theta_start + theta_total * ((i + 1) as f64 / nu as f64); - - let p = [ - torus_pt( - cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta0, phi0, - ), - torus_pt( - cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta0, phi1, - ), - torus_pt( - cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta1, phi1, - ), - torus_pt( - cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta1, phi0, - ), - ]; - - // Outward tube normal. - let mid_phi = (phi0 + phi1) * 0.5; - let mid_theta = (theta0 + theta1) * 0.5; - // Direction from tube center to surface point. - let radial = [ - u_dir[0] * mid_phi.cos() + v_dir[0] * mid_phi.sin(), - u_dir[1] * mid_phi.cos() + v_dir[1] * mid_phi.sin(), - u_dir[2] * mid_phi.cos() + v_dir[2] * mid_phi.sin(), - ]; - let n = norm3([ - radial[0] * mid_theta.cos() + axis[0] * mid_theta.sin(), - radial[1] * mid_theta.cos() + axis[1] * mid_theta.sin(), - radial[2] * mid_theta.cos() + axis[2] * mid_theta.sin(), - ]); - - push_quad(verts, normals, indices, p, n); - } - } -} - -#[inline] -fn torus_pt( - cx: f64, - cy: f64, - cz: f64, - axis: [f64; 3], - u_dir: [f64; 3], - v_dir: [f64; 3], - major_r: f64, - minor_r: f64, - theta: f64, // tube angle - phi: f64, // revolution angle -) -> [f64; 3] { - // Parametric radial direction at angle phi. Keep this independent from the - // signed major radius: a zero-major spindle is still well-defined, while - // normalizing `ring - center` would collapse it and would flip the tube - // frame whenever the stored major radius is negative. - let radial = [ - u_dir[0] * phi.cos() + v_dir[0] * phi.sin(), - u_dir[1] * phi.cos() + v_dir[1] * phi.sin(), - u_dir[2] * phi.cos() + v_dir[2] * phi.sin(), - ]; - let ring = [ - cx + major_r * radial[0], - cy + major_r * radial[1], - cz + major_r * radial[2], - ]; - // Point on tube. - [ - ring[0] + minor_r * (radial[0] * theta.cos() + axis[0] * theta.sin()), - ring[1] + minor_r * (radial[1] * theta.cos() + axis[1] * theta.sin()), - ring[2] + minor_r * (radial[2] * theta.cos() + axis[2] * theta.sin()), - ] -} - -// ── Math helpers ────────────────────────────────────────────────────────────── - -#[inline] -fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} - - -#[inline] -fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} - -#[inline] -fn norm3(v: [f64; 3]) -> [f64; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if len < 1e-12 { - [0.0, 0.0, 1.0] - } else { - [v[0] / len, v[1] / len, v[2] / len] - } + finish( + sat, + solid.common.handle.value().to_string(), + color, + facet_res, + isolines, + &solid.acis_data, + ) } diff --git a/src/scene/convert/spline_tess.rs b/src/scene/convert/spline_tess.rs deleted file mode 100644 index 8a4e6ac8..00000000 --- a/src/scene/convert/spline_tess.rs +++ /dev/null @@ -1,454 +0,0 @@ -// B-spline (NURBS) surface tessellation for ACIS `spline-surface` faces. -// -// Lofted / swept / revolved surfaces store their geometry as an ACIS -// `nubs` (non-uniform B-spline) block inside the `spline-surface` record. -// Rather than evaluate the basis functions by hand, we parse the control net -// and knot vectors out of the SAT tokens, hand them to the kernel's -// `BSplineSurface` (the same NURBS kernel the Model tab already builds on), -// and sample its parametric grid into triangles. - -use acadrust::entities::acis::types::Sense; -use acadrust::entities::acis::{ - SatCoedge, SatDocument, SatFace, SatLoop, SatPCurve, SatRecord, SatSplineSurface, SatToken, -}; -use rustc_hash::FxHashSet; -use acadrust::kernel::space::NurbsSurface3; - -use crate::scene::convert::solid3d_tess::LodConfig; - -// A `spline-surface` block is either a non-rational `nubs`, whose control -// points are plain xyz, or a rational `nurbs`, whose carry a weight. The -// kernel's surface holds both — weights absent means polynomial — so there is -// nothing here to tell apart. - -/// Tessellate one `spline-surface` face by sampling its B-spline surface. -/// Appends triangles to the shared mesh buffers; a no-op when the surface -/// record can't be parsed into a B-spline. -pub fn tess_spline_face( - sat: &SatDocument, - face: &SatFace, - lod: LodConfig, - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - indices: &mut Vec, -) -> bool { - let Some(surf_rec) = sat.resolve(face.surface()) else { - return false; - }; - let Some(surface) = build_spline_surface(sat, surf_rec) else { - return false; - }; - - // Sample over the knot domain and clip cells against ACIS pcurves when the - // face carries a parametric trim. This preserves holes and non-rectangular - // spline faces instead of always filling the complete UV rectangle. - let ((u0, u1), (v0, v1)) = surface.domain(); - if !(u1 > u0) || !(v1 > v0) { - return false; - } - - // A B-spline patch has no single analytic radius to drive a chord-tolerance - // count, so sample at the LOD's nominal density (its unit-circle segment - // count). Floor 8 so a curved patch stays smooth. - let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8); - let (su, sv) = (n, n); - let trim_loops = collect_trim_loops(sat, face, n); - let reversed = matches!(face.sense(), Sense::Reversed); - let index_start = indices.len(); - - let base = verts.len() as u32; - for j in 0..=sv { - let v = v0 + (v1 - v0) * (j as f64 / sv as f64); - for i in 0..=su { - let u = u0 + (u1 - u0) * (i as f64 / su as f64); - let p = surface.point_at_knot(u, v); - // A pole, or a row of coincident control points, has no plane to - // be perpendicular to. Up is as good an answer as any there and - // better than an invented one, since the patch has no area at - // that point to shade. - let mut n = surface.normal_at_knot(u, v).unwrap_or([0.0, 0.0, 1.0]); - if reversed { - n = [-n[0], -n[1], -n[2]]; - } - verts.push(p); - normals.push([n[0] as f32, n[1] as f32, n[2] as f32]); - } - } - - let row = (su + 1) as u32; - for j in 0..sv as u32 { - for i in 0..su as u32 { - if let Some(loops) = trim_loops.as_ref() { - let u = u0 + (u1 - u0) * ((i as f64 + 0.5) / su as f64); - let v = v0 + (v1 - v0) * ((j as f64 + 0.5) / sv as f64); - if !inside_trim((u, v), loops, (u0, u1, v0, v1)) { - continue; - } - } - let a = base + j * row + i; - let b = a + 1; - let c = a + row; - let d = c + 1; - if reversed { - indices.extend_from_slice(&[a, d, b, a, c, d]); - } else { - indices.extend_from_slice(&[a, b, d, a, d, c]); - } - } - } - indices.len() > index_start -} - -/// Collect complete face-loop pcurves in UV space. Missing pcurves disable -/// clipping for that face; a partial trim would be worse than the old full -/// patch fallback. -fn collect_trim_loops( - sat: &SatDocument, - face: &SatFace, - segments: usize, -) -> Option>> { - let mut result = Vec::new(); - let mut loop_ptr = face.first_loop(); - let mut seen_loops = FxHashSet::default(); - while !loop_ptr.is_null() && seen_loops.insert(loop_ptr.0) { - let sat_loop = SatLoop::from_record(sat.resolve(loop_ptr)?)?; - let first = sat_loop.first_coedge(); - let mut coedge_ptr = first; - let mut seen_coedges = FxHashSet::default(); - let mut polygon: Vec<(f64, f64)> = Vec::new(); - while !coedge_ptr.is_null() && seen_coedges.insert(coedge_ptr.0) { - let coedge = SatCoedge::from_record(sat.resolve(coedge_ptr)?)?; - let pcurve = SatPCurve::from_record(sat.resolve(coedge.pcurve())?)?; - let mut points = pcurve.sample_in(sat, segments); - if points.len() < 2 { - return None; - } - if matches!(coedge.sense(), Sense::Reversed) { - points.reverse(); - } - if let Some(&last) = polygon.last() { - let first_gap = - (last.0 - points[0].0).powi(2) + (last.1 - points[0].1).powi(2); - let end = points[points.len() - 1]; - let last_gap = (last.0 - end.0).powi(2) + (last.1 - end.1).powi(2); - if last_gap < first_gap { - points.reverse(); - } - } - points.pop(); - polygon.extend(points); - coedge_ptr = coedge.next(); - if coedge_ptr == first { - break; - } - } - if polygon.len() < 3 { - return None; - } - result.push(polygon); - loop_ptr = sat_loop.next_loop(); - } - if result.is_empty() { - None - } else { - Some(result) - } -} - -fn inside_trim( - point: (f64, f64), - loops: &[Vec<(f64, f64)>], - domain: (f64, f64, f64, f64), -) -> bool { - let domain_area = ((domain.1 - domain.0) * (domain.3 - domain.2)).abs(); - let area_epsilon = domain_area.max(1.0) * 1e-10; - let periodic_boundary = loops.iter().any(|polygon| { - if polygon_area(polygon).abs() > area_epsilon { - return false; - } - let bounds = polygon_bounds(polygon); - let u_span = (bounds[2] - bounds[0]).abs(); - let v_span = (bounds[3] - bounds[1]).abs(); - u_span >= (domain.1 - domain.0).abs() * 0.9 - || v_span >= (domain.3 - domain.2).abs() * 0.9 - }); - if periodic_boundary { - return loops.iter().all(|polygon| { - polygon_area(polygon).abs() <= area_epsilon - || !point_in_polygon(point, polygon) - }); - } - - let Some((outer_index, _)) = loops - .iter() - .enumerate() - .map(|(index, polygon)| (index, polygon_area(polygon).abs())) - .max_by(|a, b| a.1.total_cmp(&b.1)) - else { - return true; - }; - point_in_polygon(point, &loops[outer_index]) - && loops - .iter() - .enumerate() - .all(|(index, polygon)| index == outer_index || !point_in_polygon(point, polygon)) -} - -fn polygon_bounds(polygon: &[(f64, f64)]) -> [f64; 4] { - polygon.iter().fold( - [ - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, - ], - |mut bounds, &(u, v)| { - bounds[0] = bounds[0].min(u); - bounds[1] = bounds[1].min(v); - bounds[2] = bounds[2].max(u); - bounds[3] = bounds[3].max(v); - bounds - }, - ) -} - -fn polygon_area(polygon: &[(f64, f64)]) -> f64 { - polygon - .iter() - .zip(polygon.iter().cycle().skip(1)) - .take(polygon.len()) - .map(|(&(ax, ay), &(bx, by))| ax * by - bx * ay) - .sum::() - * 0.5 -} - -fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool { - let (x, y) = point; - let mut inside = false; - let mut previous = polygon[polygon.len() - 1]; - for ¤t in polygon { - let crosses = (current.1 > y) != (previous.1 > y) - && x - < (previous.0 - current.0) * (y - current.1) - / (previous.1 - current.1) - + current.0; - if crosses { - inside = !inside; - } - previous = current; - } - inside -} - -/// Parse the `nubs` control net + knot vectors out of a `spline-surface` -/// record's token stream into a kernel surface. -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)?; - } - // Locate the real B-spline block. `nullbs` placeholders (for absent - // rail/path surfaces) precede it; the actual surface is `nubs` (plain xyz - // control points) or `nurbs` (rational — each control point carries a - // weight, so it is stored as xyzw). - let start = toks - .iter() - .rposition(|t| matches!(t, SatToken::Ident(s) if s == "nubs" || s == "nurbs"))?; - let rational = matches!(&toks[start], SatToken::Ident(s) if s == "nurbs"); - - let mut p = start + 1; - let deg_u = read_int(toks, &mut p)? as usize; - let deg_v = read_int(toks, &mut p)? as usize; - // Four form flags (closure / singularity in u and v) — skip. - for _ in 0..4 { - read_int(toks, &mut p)?; - } - let n_uknot = read_int(toks, &mut p)? as usize; - let n_vknot = read_int(toks, &mut p)? as usize; - - let raw_u_knots = read_knot_vec(toks, &mut p, n_uknot)?; - let raw_v_knots = read_knot_vec(toks, &mut p, n_vknot)?; - let stride = if rational { 4 } else { 3 }; - let available = toks[p..] - .iter() - .take_while(|token| token.as_float().is_some()) - .count(); - let base_u = raw_u_knots.len().checked_sub(deg_u + 1)?; - let base_v = raw_v_knots.len().checked_sub(deg_v + 1)?; - let mut best: Option<(usize, usize, bool, bool, usize)> = None; - for clamp_u in [false, true] { - for clamp_v in [false, true] { - let n_ctrl_u = base_u + usize::from(clamp_u) * 2; - let n_ctrl_v = base_v + usize::from(clamp_v) * 2; - if n_ctrl_u <= deg_u || n_ctrl_v <= deg_v { - continue; - } - let needed = n_ctrl_u.checked_mul(n_ctrl_v)?.checked_mul(stride)?; - if needed > available { - continue; - } - let remaining = available - needed; - if best.as_ref().is_none_or(|candidate| remaining < candidate.4) { - best = Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, remaining)); - } - } - } - let Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, _)) = best else { - if std::env::var_os("OCS_TESS_DEBUG").is_some() { - eprintln!( - "acis_spline_parse[{}]: no control-net match degree={deg_u}x{deg_v} raw_knots={}x{} available={available}", - rec.index, - raw_u_knots.len(), - raw_v_knots.len() - ); - } - return None; - }; - let u_knots = with_clamped_ends(raw_u_knots, clamp_u)?; - let v_knots = with_clamped_ends(raw_v_knots, clamp_v)?; - - // Control points are stored row-major with u varying fastest (a full row - // of u control points per v step). the kernel wants `ctrl[i_u][j_v]`. - let total = n_ctrl_u * n_ctrl_v; - let mut flat: Vec<[f64; 3]> = Vec::with_capacity(total); - let mut flat_weights: Vec = Vec::with_capacity(total); - for _ in 0..total { - let x = read_float(toks, &mut p)?; - let y = read_float(toks, &mut p)?; - let z = read_float(toks, &mut p)?; - // A rational net stores the weight alongside each point, and the - // point itself unweighted — the kernel carries the two separately and - // does the homogeneous multiply where it belongs. - flat_weights.push(if rational { read_float(toks, &mut p)? } else { 1.0 }); - flat.push([x, y, z]); - } - let mut net = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u]; - let mut weights = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u]; - for v in 0..n_ctrl_v { - for u in 0..n_ctrl_u { - net[u].push(flat[v * n_ctrl_u + u]); - weights[u].push(flat_weights[v * n_ctrl_u + u]); - } - } - let surface = NurbsSurface3::new( - deg_u, - deg_v, - net, - u_knots, - v_knots, - rational.then_some(weights), - ); - if surface.is_none() && std::env::var_os("OCS_TESS_DEBUG").is_some() { - eprintln!( - "acis_spline_parse[{}]: surface rejected degree={deg_u}x{deg_v} control={n_ctrl_u}x{n_ctrl_v}", - rec.index - ); - } - surface -} - -fn build_decoded_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option { - let spline = SatSplineSurface::from_record(rec)?; - let decoded = spline.bspline(sat)?; - - // Row-major with u varying fastest, which is how ACIS writes it and the - // other way round from the net the kernel reads. - let mut net = vec![Vec::with_capacity(decoded.control_count_v); decoded.control_count_u]; - let mut weights = 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]; - net[u].push([point[0], point[1], point[2]]); - weights[u].push(point[3]); - } - } - NurbsSurface3::new( - decoded.degree_u, - decoded.degree_v, - net, - decoded.u_knots, - decoded.v_knots, - decoded.rational.then_some(weights), - ) -} - -/// 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 -/// have been read. -fn read_knot_vec( - toks: &[SatToken], - p: &mut usize, - count: usize, -) -> Option> { - let mut knots: Vec = Vec::new(); - for _ in 0..count { - let value = read_float(toks, p)?; - let mult = read_int(toks, p)? as usize; - for _ in 0..mult { - knots.push(value); - } - } - if knots.len() < 2 { - return None; - } - Some(knots) -} - -fn with_clamped_ends(mut knots: Vec, clamp: bool) -> Option> { - if clamp { - let first = *knots.first()?; - let last = *knots.last()?; - knots.insert(0, first); - knots.push(last); - } - Some(knots) -} - -fn primary_subtype_reference(tokens: &[SatToken]) -> Option { - let start = tokens - .iter() - .position(|token| token.as_ident() == Some("{"))?; - if tokens.get(start + 1).and_then(SatToken::as_ident) != Some("ref") - || tokens.get(start + 3).and_then(SatToken::as_ident) != Some("}") - { - return None; - } - tokens - .get(start + 2)? - .as_integer() - .and_then(|index| usize::try_from(index).ok()) -} - -fn read_int(toks: &[SatToken], p: &mut usize) -> Option { - while *p < toks.len() { - let t = &toks[*p]; - *p += 1; - match t { - SatToken::Integer(v) => return Some(*v), - SatToken::Float(v) => return Some(*v as i64), - // Skip block delimiters / idents that may appear inline. - SatToken::Ident(_) | SatToken::Enum(_) => continue, - _ => return None, - } - } - None -} - -fn read_float(toks: &[SatToken], p: &mut usize) -> Option { - while *p < toks.len() { - let t = &toks[*p]; - *p += 1; - match t { - SatToken::Float(v) => return Some(*v), - SatToken::Integer(v) => return Some(*v as f64), - SatToken::Ident(_) | SatToken::Enum(_) => continue, - _ => return None, - } - } - None -} diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 7bf2dcc9..4bc0e254 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1922,7 +1922,6 @@ fn fallback_geometry(entity: &EntityType) -> Geometry { | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => { - let pts = solid_wire_fallback(entity); let mut snap = vec![]; if let Some(p) = crate::entities::solid3d::point_of_reference(entity) { snap.push(( @@ -1930,7 +1929,7 @@ fn fallback_geometry(entity: &EntityType) -> Geometry { SnapHint::Insertion, )); } - (pts, snap, vec![], vec![]) + (vec![], snap, vec![], vec![]) } _ => { let s = 0.5_f64; @@ -1939,39 +1938,6 @@ fn fallback_geometry(entity: &EntityType) -> Geometry { } } -/// Extract pre-computed edge-wire points from Solid3D / Region / Body entities. -/// -/// Some drawings store explicit wire geometry alongside the -/// ACIS data. We use this as a visible fallback when the SAT tessellator -/// produces no mesh (e.g. binary SAB data or unsupported geometry). -fn solid_wire_fallback(entity: &EntityType) -> Vec<[f64; 3]> { - let Some(wires) = crate::entities::solid3d::fallback_wires(entity) else { - return vec![]; - }; - if wires.is_empty() { - return vec![]; - } - // Fully supported ACIS → mesh pipeline draws body. Parseable-but-partial - // ACIS keeps source display wires so unsupported faces never disappear. - if crate::entities::solid3d::acis_has_complete_surface_support(entity) { - return vec![]; - } - - let mut pts: Vec<[f64; 3]> = Vec::new(); - for wire in wires { - if wire.points.len() < 2 { - continue; - } - for v in &wire.points { - let transformed = crate::entities::solid3d::wire_point(wire, v); - pts.push([transformed.x, transformed.y, transformed.z]); - } - // NaN sentinel separates distinct wire segments. - pts.push([f64::NAN, f64::NAN, f64::NAN]); - } - pts -} - pub(crate) fn push_tri(out: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3, c: Vec3) { out.push([a.x, a.y, a.z]); out.push([b.x, b.y, b.z]); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7a105a98..b61a30da 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1224,104 +1224,25 @@ fn transform_block_mesh_lod_set( use acadrust::types::Vector3; let mut out = set.clone(); out.instance_transform = Some(*xform); - let transform_direction = |direction: [f32; 3]| { - let transformed = xform.apply_rotation(Vector3::new( - direction[0] as f64, - direction[1] as f64, - direction[2] as f64, - )); - let length = transformed.length(); - if length > 1e-12 { - [ - (transformed.x / length) as f32, - (transformed.y / length) as f32, - (transformed.z / length) as f32, - ] + let origin = xform.apply(Vector3::ZERO); + let vectors = [ + xform.apply_rotation(Vector3::UNIT_X), + xform.apply_rotation(Vector3::UNIT_Y), + xform.apply_rotation(Vector3::UNIT_Z), + ]; + out.curved_gens.retain_mut(|generator| { + let transformed = acadrust::kernel::brep::mesh::transform_silhouette_affine( + &generator.source, + vectors.map(|vector| [vector.x, vector.y, vector.z]), + [origin.x, origin.y, origin.z], + ); + if let Some(source) = transformed { + generator.source = source; + true } else { - direction + false } - }; - let scale_x = xform.apply_rotation(Vector3::UNIT_X).length(); - let scale_y = xform.apply_rotation(Vector3::UNIT_Y).length(); - let scale_z = xform.apply_rotation(Vector3::UNIT_Z).length(); - let uniform_scale = (scale_x + scale_y + scale_z) / 3.0; - let is_uniform = (scale_x - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8 - && (scale_y - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8 - && (scale_z - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8; - if is_uniform { - let transform_split = |high: &mut [f32; 3], low: &mut [f32; 3]| { - let transformed = xform.apply(Vector3::new( - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - high[2] as f64 + low[2] as f64, - )); - *high = [ - transformed.x as f32, - transformed.y as f32, - transformed.z as f32, - ]; - *low = [ - (transformed.x - high[0] as f64) as f32, - (transformed.y - high[1] as f64) as f32, - (transformed.z - high[2] as f64) as f32, - ]; - }; - for generator in &mut out.curved_gens { - match generator { - crate::scene::model::mesh_model::CurvedGen::Cone { - base, - base_low, - axis, - u_dir, - v_dir, - radius, - h_max, - .. - } => { - transform_split(base, base_low); - *axis = transform_direction(*axis); - *u_dir = transform_direction(*u_dir); - *v_dir = transform_direction(*v_dir); - *radius *= uniform_scale as f32; - *h_max *= uniform_scale as f32; - } - crate::scene::model::mesh_model::CurvedGen::Sphere { - center, - center_low, - pole, - u_dir, - v_dir, - radius, - .. - } => { - transform_split(center, center_low); - *pole = transform_direction(*pole); - *u_dir = transform_direction(*u_dir); - *v_dir = transform_direction(*v_dir); - *radius *= uniform_scale as f32; - } - crate::scene::model::mesh_model::CurvedGen::Torus { - center, - center_low, - axis, - u_dir, - v_dir, - major, - minor, - .. - } => { - transform_split(center, center_low); - *axis = transform_direction(*axis); - *u_dir = transform_direction(*u_dir); - *v_dir = transform_direction(*v_dir); - *major *= uniform_scale as f32; - *minor *= uniform_scale as f32; - } - } - } - } else { - out.curved_gens.clear(); - } + }); let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; @@ -1395,41 +1316,6 @@ fn transform_block_mesh_lod_set( ]; } } - for silhouette in &mut out.stored_silhouettes { - silhouette.view_direction = transform_direction(silhouette.view_direction); - silhouette.up_vector = transform_direction(silhouette.up_vector); - let target = xform.apply(Vector3::new( - silhouette.target[0] as f64, - silhouette.target[1] as f64, - silhouette.target[2] as f64, - )); - silhouette.target = [target.x as f32, target.y as f32, target.z as f32]; - let count = silhouette.edge_verts.len(); - if silhouette.edge_verts_low.len() != count { - silhouette.edge_verts_low = vec![[0.0; 3]; count]; - } - for (high, low) in silhouette - .edge_verts - .iter_mut() - .zip(silhouette.edge_verts_low.iter_mut()) - { - let transformed = xform.apply(Vector3::new( - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - high[2] as f64 + low[2] as f64, - )); - *high = [ - transformed.x as f32, - transformed.y as f32, - transformed.z as f32, - ]; - *low = [ - (transformed.x - high[0] as f64) as f32, - (transformed.y - high[1] as f64) as f32, - (transformed.z - high[2] as f64) as f32, - ]; - } - } if min_x.is_finite() { out.world_aabb = [min_x, min_y, max_x, max_y]; } diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index b3a6a520..98024ba9 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -42,76 +42,10 @@ pub struct MeshModel { /// /// `lods` holds up to one MeshModel per LOD level (high → low). Empty /// slots fall back to the nearest available LOD at render time. -/// A curved face's generator, kept so a view-dependent silhouette (DISPSILH) -/// can be computed per frame — the silhouette is where the surface turns away -/// from the eye, which no baked edge can capture. World-space, post body -/// transform; base/centre points carry a double-single low half so they stay -/// precise at UTM scale like the mesh verts. Each variant also carries the -/// face's parametric extent so the silhouette is clipped to the actual face -/// rather than drawn across the whole (possibly partial) surface. -#[derive(Clone, Copy, Debug)] -pub enum CurvedGen { - /// Cone / cylinder: two edge-on lines up the surface. - Cone { - base: [f32; 3], - base_low: [f32; 3], - axis: [f32; 3], - /// Radial frame: `u` is the θ=0 direction, `v = axis × u`. - u_dir: [f32; 3], - v_dir: [f32; 3], - /// Radius at the base (`h = 0`). - radius: f32, - /// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`. - tan_a: f32, - /// Height span along the axis the face covers (base is `h = 0`). - h_max: f32, - theta_min: f32, - theta_span: f32, - full: bool, - }, - /// Sphere: the great circle perpendicular to the view, clipped to the - /// face's longitude/colatitude window. - Sphere { - center: [f32; 3], - center_low: [f32; 3], - pole: [f32; 3], - u_dir: [f32; 3], - v_dir: [f32; 3], - radius: f32, - theta_min: f32, - theta_span: f32, - full: bool, - phi_min: f32, - phi_max: f32, - }, - /// Torus: view-dependent tube silhouette, clipped to both parametric - /// windows the face covers. - Torus { - center: [f32; 3], - center_low: [f32; 3], - axis: [f32; 3], - u_dir: [f32; 3], - v_dir: [f32; 3], - major: f32, - minor: f32, - phi_min: f32, - phi_span: f32, - full: bool, - theta_min: f32, - theta_span: f32, - theta_full: bool, - }, -} - +/// Kernel-owned source for a view-dependent silhouette. #[derive(Clone, Debug)] -pub struct StoredSilhouette { - pub viewport_id: i64, - pub view_direction: [f32; 3], - pub up_vector: [f32; 3], - pub target: [f32; 3], - pub is_perspective: bool, - pub edge_verts: Vec<[f32; 3]>, - pub edge_verts_low: Vec<[f32; 3]>, +pub struct CurvedGen { + pub source: acadrust::kernel::brep::mesh::SilhouetteSource, } #[derive(Clone, Copy, Debug, Default)] @@ -149,13 +83,8 @@ pub struct MeshLodSet { pub edge_verts: Vec<[f32; 3]>, /// Low residual paired with `edge_verts`. pub edge_verts_low: Vec<[f32; 3]>, - /// Curved-face generators for per-frame silhouette (DISPSILH). Empty for a - /// solid with no curved faces, or when silhouettes aren't wanted. + /// Kernel sources for per-frame silhouettes. pub curved_gens: Vec, - /// View-specific silhouette caches stored in COMMON_3DSOLID. They are used - /// when the decoded surface family cannot provide a live analytic - /// silhouette for the current view. - pub stored_silhouettes: Vec, /// Geometry measurements calculated once from the highest available LOD. /// Properties can read these without re-parsing or re-tessellating ACIS on /// the UI thread. @@ -291,7 +220,6 @@ impl MeshLodSet { edge_verts: Vec::new(), edge_verts_low: Vec::new(), curved_gens: Vec::new(), - stored_silhouettes: Vec::new(), metrics, world_aabb, z_aabb, diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 8f47ffab..8e56333c 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -25,6 +25,10 @@ const SAG: f64 = 0.05; /// What counts as the same point when the kernel checks a body over. const TOL: f64 = 1e-9; +fn tessellation(body: &Body) -> brep::mesh::BodyMesh { + brep::mesh::tessellate(body, brep::mesh::TessellationTolerance::new(SAG, TOL)) +} + /// Axis-aligned box from its center and full extents. pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option { brep::make::cuboid( @@ -143,7 +147,7 @@ fn about_origin(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: [f64; 3]) -> [f64; /// The box a body occupies, from its mesh. pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> { - let mesh = brep::mesh::body(body, SAG, TOL); + let mesh = tessellation(body).mesh; if mesh.positions.is_empty() { return None; } @@ -165,7 +169,7 @@ pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> { /// is a set of Line entities either way. Each triangle the plane crosses /// contributes the one segment where it does. pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3])> { - let mesh = brep::mesh::body(body, SAG, TOL); + let mesh = tessellation(body).mesh; let mut out = Vec::new(); for triangle in &mesh.triangles { let corners: Vec<[f64; 3]> = triangle.iter().map(|i| mesh.positions[*i]).collect(); @@ -201,16 +205,15 @@ pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3]) // ── Edge extraction (pick geometry + wireframe overlay) ───────────────────── /// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the -/// `Solid3D`/result entity so it is click-pickable (the renderer's wire -/// fallback draws these as a wireframe over the shaded mesh, and hit-testing -/// uses their points). +/// `Solid3D`/result entity for picking. pub fn edge_wires(body: &Body) -> Vec { use acadrust::types::Vector3; - brep::edge_polylines(body, SAG) + tessellation(body) + .edges .into_iter() - .map(|points| { + .map(|edge| { acadrust::entities::Wire::from_points( - points + edge.positions .into_iter() .map(|p| Vector3::new(p[0], p[1], p[2])) .collect(), @@ -250,7 +253,9 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { /// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before /// world_offset is applied by the caller). pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { - let mesh = brep::mesh::body(body, SAG, TOL); + let tessellation = tessellation(body); + let silhouette = tessellation.silhouette_source(); + let mesh = tessellation.mesh; if mesh.is_empty() { return None; } @@ -278,7 +283,7 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { .iter() .flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32]) .collect(); - Some(MeshLodSet::from_single(MeshModel { + let mut set = MeshLodSet::from_single(MeshModel { name: String::new(), verts, verts_low, @@ -288,7 +293,23 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { triangle_colors: Vec::new(), color, selected: false, - })) + }); + for edge in tessellation.edges { + for segment in edge.positions.windows(2) { + for point in segment { + let high = [point[0] as f32, point[1] as f32, point[2] as f32]; + set.edge_verts.push(high); + set.edge_verts_low.push([ + (point[0] - high[0] as f64) as f32, + (point[1] - high[1] as f64) as f32, + (point[2] - high[2] as f64) as f32, + ]); + } + } + } + set.complete = tessellation.missing_faces.is_empty(); + set.curved_gens.push(super::mesh_model::CurvedGen { source: silhouette }); + Some(set) } /// The middle of a body, for a caller needing a point to turn or scale about. @@ -296,7 +317,7 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { /// Read off the mesh rather than `body_bounds`, which refuses a face that /// wraps a closed surface — a sphere is one such face and has no box at all. pub fn centre(body: &Body) -> Option<[f64; 3]> { - let mesh = brep::mesh::body(body, SAG, TOL); + let mesh = tessellation(body).mesh; if mesh.positions.is_empty() { return None; } @@ -325,7 +346,7 @@ pub fn centre(body: &Body) -> Option<[f64; 3]> { #[cfg(test)] pub fn volume(body: &Body) -> f64 { use acadrust::kernel::space::Vec3; - let mesh = brep::mesh::body(body, SAG, TOL); + let mesh = tessellation(body).mesh; let Some(middle) = centre(body) else { return 0.0; }; diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index e9f3dded..ef25826b 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -14,7 +14,7 @@ use acadrust::kernel::brep::{self, Body}; use acadrust::kernel::geom2d::Curve; -use acadrust::kernel::space::{PlanarCurve, Plane, Vec3}; +use acadrust::kernel::space::{PlanarCurve, Plane}; use acadrust::EntityType; use crate::entities::curve::entity_curve; @@ -47,7 +47,7 @@ pub fn profile_of(entity: &EntityType) -> Option { // both the fewest pieces a chain may have and the fewest that leave // each one unambiguous about which way round it goes. Curve::Circle(circle) => quarters(circle.centre, circle.radius), - other => split_evenly(other, 4), + _ => return None, }; (pieces.len() >= 3).then_some(Profile { plane: planar.plane, @@ -72,21 +72,6 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec { .collect() } -/// Any other closed curve as `count` straight pieces between points on it. -/// -/// The honest fallback: an ellipse or a spline has no analytic sweep, so the -/// kernel would refuse the exact form anyway. Chords at least say plainly -/// what they are. -fn split_evenly(curve: &Curve, count: usize) -> Vec { - use acadrust::kernel::geom2d::Line; - (0..count) - .map(|step| Curve::Line(Line { - start: curve.point_at(step as f64 / count as f64), - end: curve.point_at((step + 1) as f64 / count as f64), - })) - .collect() -} - /// EXTRUDE: drag the profile `height` along its own plane's normal. /// /// `None` for a profile that does not close, encloses nothing, or holds a @@ -123,226 +108,85 @@ pub fn revolved( ) } -// ── SWEEP and LOFT ────────────────────────────────────────────────────────── -// -// Neither keeps a B-rep — both have only ever produced a mesh — so both are -// built from point lists rather than from topology. A profile becomes the -// points its own curve tessellates to, which is the same source EXTRUDE and -// REVOLVE read, so a circle stays round here too. - -use crate::entities::curve::curve_points; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; -/// The points a profile entity traces, and whether it closes. -fn outline(entity: &EntityType) -> Option<(Vec<[f64; 3]>, bool)> { - let planar = entity_curve(entity)?; - let closed = planar.curve.is_closed(); - let mut points = curve_points(&planar); - // A closed curve tessellates back to its own start. Carrying the repeat - // would put a zero-width quad in every strip below. - if closed && points.len() > 1 { - let first = points[0]; - let last = points[points.len() - 1]; - if Vec3::from(first).distance(Vec3::from(last)) < 1e-9 { - points.pop(); - } - } - (points.len() >= 2).then_some((points, closed)) -} - -/// SWEEP: drag a profile along a path. -/// -/// The path contributes its direction and length, not its shape — which is -/// what SWEEP has always done here, and what makes it an extrusion along an -/// arbitrary vector rather than along a curve. +/// SWEEP through the kernel's tolerance-driven mesh API. pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option { - let (points, closed) = outline(profile)?; - let along = { - let track = curve_points(&entity_curve(path)?); - let (from, to) = (Vec3::from(*track.first()?), Vec3::from(*track.last()?)); - to - from - }; - if along.length() < 1e-12 { - return None; - } - let moved: Vec<[f64; 3]> = points - .iter() - .map(|point| (Vec3::from(*point) + along).to_array()) - .collect(); - let mut mesh = Ribbon::default(); - mesh.band(&points, &moved, closed); - if closed { - // An open profile sweeps into a sheet with nothing to cap. - mesh.cap(&points, true); - mesh.cap(&moved, false); - } - mesh.finish(color) + let tolerance = crate::scene::convert::curve_tol::current_curve_tol(); + let surface = brep::mesh::sweep_surface( + &entity_curve(profile)?, + &entity_curve(path)?, + tolerance, + )?; + mesh_set(surface, color, tolerance) } -/// LOFT: rule a surface through a run of profiles. -/// -/// Consecutive profiles are joined by a band each, and the two ends are -/// capped when they close. Profiles with different point counts are resampled -/// onto the finer of the two, so a circle lofted to a square does not twist. +/// LOFT through the kernel's tolerance-driven mesh API. pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option { - let sections: Vec<(Vec<[f64; 3]>, bool)> = profiles.iter().filter_map(outline).collect(); - if sections.len() < 2 { + let curves: Vec = profiles.iter().filter_map(entity_curve).collect(); + let tolerance = crate::scene::convert::curve_tol::current_curve_tol(); + mesh_set(brep::mesh::loft_surface(&curves, tolerance)?, color, tolerance) +} + +fn mesh_set( + surface: brep::mesh::SurfaceMesh, + color: [f32; 4], + tolerance: f64, +) -> Option { + if surface.mesh.is_empty() { return None; } - let mut mesh = Ribbon::default(); - for pair in sections.windows(2) { - let count = pair[0].0.len().max(pair[1].0.len()); - let closed = pair[0].1 && pair[1].1; - let lower = resampled(&pair[0].0, count, pair[0].1); - let upper = resampled(&pair[1].0, count, pair[1].1); - mesh.band(&lower, &upper, closed); + let mut verts = Vec::with_capacity(surface.mesh.positions.len()); + let mut verts_low = Vec::with_capacity(surface.mesh.positions.len()); + for point in &surface.mesh.positions { + push_point(&mut verts, &mut verts_low, *point); } - if sections.first()?.1 { - mesh.cap(§ions.first()?.0, true); - } - if sections.last()?.1 { - mesh.cap(§ions.last()?.0, false); - } - mesh.finish(color) -} - -/// A ring walked in `count` even steps along its own length. -/// -/// Even by distance rather than by index: two profiles given at different -/// densities line up where they are, so a band between them does not twist -/// wherever one of them happened to be sampled more finely. -fn resampled(points: &[[f64; 3]], count: usize, closed: bool) -> Vec<[f64; 3]> { - let mut ring: Vec = points.iter().map(|point| Vec3::from(*point)).collect(); - if closed { - ring.push(ring[0]); - } - let mut walked = vec![0.0]; - for pair in ring.windows(2) { - walked.push(walked[walked.len() - 1] + pair[0].distance(pair[1])); - } - let total = *walked.last().unwrap_or(&0.0); - if total <= 0.0 { - return points.to_vec(); - } - let steps = if closed { count } else { count.max(2) - 1 }; - (0..if closed { count } else { count.max(2) }) - .map(|step| { - let want = total * step as f64 / steps as f64; - let at = walked - .iter() - .rposition(|reached| *reached <= want) - .unwrap_or(0) - .min(ring.len() - 2); - let span = walked[at + 1] - walked[at]; - let along = if span > 0.0 { (want - walked[at]) / span } else { 0.0 }; - ring[at].lerp(ring[at + 1], along).to_array() - }) - .collect() -} - -/// Triangles being gathered from bands and caps. -#[derive(Default)] -struct Ribbon { - positions: Vec<[f64; 3]>, - normals: Vec<[f64; 3]>, - triangles: Vec<[u32; 3]>, -} - -impl Ribbon { - /// A strip of quads between two rings of the same length. - fn band(&mut self, lower: &[[f64; 3]], upper: &[[f64; 3]], closed: bool) { - let count = lower.len().min(upper.len()); - if count < 2 { - return; - } - let spans = if closed { count } else { count - 1 }; - for step in 0..spans { - let next = (step + 1) % count; - self.quad(lower[step], lower[next], upper[next], upper[step]); - } - } - - /// A flat lid over a closed ring, fanned from its middle. - /// - /// A fan rather than a proper triangulation: a lofted section can be - /// concave and a fan would then cover ground outside it, but every - /// profile these commands accept is a single closed curve, and the middle - /// of one is inside it. - fn cap(&mut self, ring: &[[f64; 3]], downward: bool) { - if ring.len() < 3 { - return; - } - let mut middle = Vec3::new(0.0, 0.0, 0.0); - for point in ring { - middle = middle + Vec3::from(*point); - } - let middle = (middle / ring.len() as f64).to_array(); - for step in 0..ring.len() { - let next = (step + 1) % ring.len(); - if downward { - self.triangle(middle, ring[next], ring[step]); - } else { - self.triangle(middle, ring[step], ring[next]); + let silhouette = surface.silhouette_source(tolerance); + let mut set = MeshLodSet::from_single(MeshModel { + name: String::new(), + verts, + verts_low, + normals: surface + .mesh + .normals + .iter() + .map(|normal| [normal[0] as f32, normal[1] as f32, normal[2] as f32]) + .collect(), + indices: surface + .mesh + .triangles + .iter() + .flatten() + .map(|index| *index as u32) + .collect(), + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color, + selected: false, + }); + { + let (high, low) = (&mut set.edge_verts, &mut set.edge_verts_low); + for edge in surface.edges { + for segment in edge.windows(2) { + for point in segment { + push_point(high, low, *point); + } } } } + set.curved_gens + .push(super::mesh_model::CurvedGen { source: silhouette }); + Some(set) +} - fn quad(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) { - self.triangle(a, b, c); - self.triangle(a, c, d); - } - - fn triangle(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3]) { - let Some(normal) = (Vec3::from(b) - Vec3::from(a)) - .cross(Vec3::from(c) - Vec3::from(a)) - .normalize() - else { - // Collapsed: no normal, and nothing to draw. - return; - }; - let base = self.positions.len() as u32; - for corner in [a, b, c] { - self.positions.push(corner); - self.normals.push(normal.to_array()); - } - self.triangles.push([base, base + 1, base + 2]); - } - - /// The gathered triangles as the renderer's mesh, or `None` for none. - fn finish(self, color: [f32; 4]) -> Option { - if self.triangles.is_empty() { - return None; - } - // The renderer holds each position as a coarse float plus a fine - // correction, so a profile at survey coordinates keeps its last - // millimetres instead of losing them to f32. - let mut verts = Vec::with_capacity(self.positions.len()); - let mut verts_low = Vec::with_capacity(self.positions.len()); - for point in &self.positions { - let high = [point[0] as f32, point[1] as f32, point[2] as f32]; - verts.push(high); - verts_low.push([ - (point[0] - high[0] as f64) as f32, - (point[1] - high[1] as f64) as f32, - (point[2] - high[2] as f64) as f32, - ]); - } - Some(MeshLodSet::from_single(MeshModel { - name: String::new(), - verts, - verts_low, - normals: self - .normals - .iter() - .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]) - .collect(), - indices: self.triangles.iter().flatten().copied().collect(), - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - })) - } +fn push_point(high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>, point: [f64; 3]) { + let coarse = [point[0] as f32, point[1] as f32, point[2] as f32]; + high.push(coarse); + low.push([ + (point[0] - coarse[0] as f64) as f32, + (point[1] - coarse[1] as f64) as f32, + (point[2] - coarse[2] as f64) as f32, + ]); } #[cfg(test)] diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 1e0da58b..5761425b 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -698,38 +698,12 @@ impl Scene { translate_split(high, low); } for generator in &mut set.curved_gens { - match generator { - crate::scene::model::mesh_model::CurvedGen::Cone { - base, - base_low, - .. - } => translate_split(base, base_low), - crate::scene::model::mesh_model::CurvedGen::Sphere { - center, - center_low, - .. - } - | crate::scene::model::mesh_model::CurvedGen::Torus { - center, - center_low, - .. - } => translate_split(center, center_low), - } - } - for silhouette in &mut set.stored_silhouettes { - silhouette.target[0] += delta[0] as f32; - silhouette.target[1] += delta[1] as f32; - silhouette.target[2] += delta[2] as f32; - if silhouette.edge_verts_low.len() != silhouette.edge_verts.len() { - silhouette.edge_verts_low = - vec![[0.0; 3]; silhouette.edge_verts.len()]; - } - for (high, low) in silhouette - .edge_verts - .iter_mut() - .zip(silhouette.edge_verts_low.iter_mut()) - { - translate_split(high, low); + let placement = acadrust::kernel::brep::Placement::at(delta); + if let Some(source) = acadrust::kernel::brep::mesh::transform_silhouette( + &generator.source, + &placement, + ) { + generator.source = source; } } set.metrics.centroid[0] += delta[0]; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 5b0c172a..435bd2b7 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -168,9 +168,7 @@ pub struct Pipeline { /// it back into the base set (issue #316). text_preview_vbuf: Option, text_preview_vcount: u32, - /// Per-frame DISPSILH silhouette line list — rebuilt every prepare() from - /// the mesh sets' curved-face generators and the current view direction, so - /// the outline tracks the camera. Reuses the mesh vertex format / pipeline. + /// Per-frame silhouette line list from the kernel mesh and current view. silhouette_vbuf: Option, silhouette_vcount: u32, /// Last requested render size (the full viewport rect, in pixels). The @@ -2290,35 +2288,17 @@ impl Pipeline { // The math lives outside the GPU method so it can be unit-tested; see the // module test below. - /// Rebuild the per-frame DISPSILH silhouette line list from the mesh sets' - /// curved-face generators and the current eye. For each cone/cylinder face - /// the silhouette runs at the two angles where the surface turns edge-on to - /// the view — `θ = φ ± acos(-tanα·(view·axis) / |view⊥|)`, which reduces to - /// `φ ± π/2` for a cylinder. Segments are uploaded in the mesh vertex format - /// so they draw through the existing wireframe pipeline. + /// Rebuild view-dependent silhouette lines through the kernel. pub fn upload_silhouettes( &mut self, device: &wgpu::Device, sets: &[crate::scene::model::mesh_model::MeshLodSet], view_dir: glam::Vec3, ) { - // Silhouettes follow the view *angle* only — a single parallel direction - // for the whole scene, not the eye-to-surface vector — so the outline - // stays put under pan and doesn't foreshorten. This is the orthographic - // silhouette a CAD wireframe expects. let view = glam::DVec3::new(view_dir.x as f64, view_dir.y as f64, view_dir.z as f64) .normalize_or(glam::DVec3::NEG_Z); - use crate::scene::model::mesh_model::CurvedGen; use crate::scene::pipeline::mesh_gpu::MeshVertex; let mut verts: Vec = Vec::new(); - let d3 = |a: [f32; 3]| glam::DVec3::new(a[0] as f64, a[1] as f64, a[2] as f64); - let lo = |c: [f32; 3], l: [f32; 3]| { - glam::DVec3::new( - c[0] as f64 + l[0] as f64, - c[1] as f64 + l[1] as f64, - c[2] as f64 + l[2] as f64, - ) - }; for set in sets { let color = set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]); let mk = |w: glam::DVec3| -> MeshVertex { @@ -2346,141 +2326,12 @@ impl Pipeline { uv_normal: [0.0; 2], } }; - for g in &set.curved_gens { - match g { - CurvedGen::Cone { - base, base_low, axis, u_dir, v_dir, radius, tan_a, - h_max, theta_min, theta_span, full, - } => { - let base = lo(*base, *base_low); - let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir)); - let Some((t0, t1)) = - silhouette_thetas(view.dot(u), view.dot(v), view.dot(axis), *tan_a as f64) - else { - continue; - }; - let r0 = *radius as f64; - let r1 = *radius as f64 + *h_max as f64 * *tan_a as f64; - for theta in [t0, t1] { - if !full { - let off = (theta - *theta_min as f64).rem_euclid(std::f64::consts::TAU); - if off > *theta_span as f64 { - continue; - } - } - let (c, s) = (theta.cos(), theta.sin()); - let radial = u * c + v * s; - verts.push(mk(base + radial * r0)); - verts.push(mk(base + radial * r1 + axis * *h_max as f64)); - } - } - CurvedGen::Sphere { - center, center_low, pole, u_dir, v_dir, radius, - theta_min, theta_span, full, phi_min, phi_max, - } => { - let c = lo(*center, *center_low); - let (pole, u, v) = (d3(*pole), d3(*u_dir), d3(*v_dir)); - let r = *radius as f64; - // Great circle in the plane perpendicular to the view. - let mut e1 = view.cross(pole); - if e1.length_squared() < 1e-12 { - e1 = view.cross(u); - } - let e1 = e1.normalize(); - let e2 = view.cross(e1).normalize(); - const N: usize = 64; - let mut prev: Option = None; - for i in 0..=N { - let a = std::f64::consts::TAU * (i as f64 / N as f64); - let dir = e1 * a.cos() + e2 * a.sin(); - // Keep only the arc that lies on the actual face. - // `full` is a *longitude* wrap flag: a dish cap sits - // on the pole and so covers every longitude while - // still ending at its seam, so the colatitude test - // always applies. A whole ball reports phi 0..π and - // passes it regardless. - let phi = dir.dot(pole).clamp(-1.0, 1.0).acos(); - let in_phi = - phi >= *phi_min as f64 && phi <= *phi_max as f64; - let in_theta = *full || { - let th = dir.dot(v).atan2(dir.dot(u)); - let toff = (th - *theta_min as f64).rem_euclid(std::f64::consts::TAU); - toff <= *theta_span as f64 - }; - let on_face = in_phi && in_theta; - let p = if on_face { Some(c + dir * r) } else { None }; - if let (Some(a), Some(b)) = (prev, p) { - verts.push(mk(a)); - verts.push(mk(b)); - } - prev = p; - } - } - CurvedGen::Torus { - center, center_low, axis, u_dir, v_dir, major, minor, - phi_min, phi_span, full, theta_min, theta_span, theta_full, - } => { - let ctr = lo(*center, *center_low); - let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir)); - let (major, minor) = (*major as f64, *minor as f64); - // True silhouette: at each revolution angle the tube is a - // circle; the two points where its normal turns edge-on - // trace two curves around the ring. Sample the revolution - // and connect consecutive edge-on points. - const N: usize = 72; - let span = if *full { std::f64::consts::TAU } else { *phi_span as f64 }; - let mut prev: [Option; 2] = [None, None]; - for i in 0..=N { - let phi = *phi_min as f64 + span * (i as f64 / N as f64); - let radial = u * phi.cos() + v * phi.sin(); - let ring = ctr + radial * major; - let (rv, av) = (radial.dot(view), axis.dot(view)); - if rv.abs() < 1e-9 && av.abs() < 1e-9 { - prev = [None, None]; - continue; - } - // tube normal(θ) = radial·cosθ + axis·sinθ; ⟂ view at - // θ = atan2(-rv, av) and +π. - let th = (-rv).atan2(av); - let cur = [th, th + std::f64::consts::PI]; - for k in 0..2 { - let t = cur[k]; - let theta_offset = - (t - *theta_min as f64).rem_euclid(std::f64::consts::TAU); - let on_face = *theta_full || theta_offset <= *theta_span as f64; - let p = on_face.then(|| { - ring + (radial * t.cos() + axis * t.sin()) * minor - }); - if let (Some(pp), Some(p)) = (prev[k], p) { - verts.push(mk(pp)); - verts.push(mk(p)); - } - prev[k] = p; - } - } - } - } - } - if set.curved_gens.is_empty() || !set.complete { - let best = set.stored_silhouettes.iter().max_by(|left, right| { - let score = |silhouette: &crate::scene::model::mesh_model::StoredSilhouette| { - let direction = d3(silhouette.view_direction) - .normalize_or(glam::DVec3::NEG_Z); - direction.dot(view).abs() - }; - score(left) - .partial_cmp(&score(right)) - .unwrap_or(std::cmp::Ordering::Equal) - }); - if let Some(silhouette) = best { - for (index, high) in silhouette.edge_verts.iter().copied().enumerate() { - let low = silhouette - .edge_verts_low - .get(index) - .copied() - .unwrap_or([0.0; 3]); - verts.push(mk(lo(high, low))); - } + for generator in &set.curved_gens { + for point in acadrust::kernel::brep::mesh::silhouette( + &generator.source, + [view.x, view.y, view.z], + ) { + verts.push(mk(glam::DVec3::from_array(point))); } } } @@ -4678,26 +4529,3 @@ impl iced::widget::shader::Pipeline for MultiPipeline { } } } - -/// The two silhouette angles of a cone/cylinder face for a view direction, -/// expressed in the face's `(u, v, axis)` frame via the view's components on -/// each: `du = view·u`, `dv = view·v`, `da = view·axis`. `tan_a` is the cone -/// taper (0 for a cylinder). -/// -/// The outward normal is edge-on to the view where `du·cosθ + dv·sinθ = -/// -tanα·da`, i.e. `θ = φ ± acos(-tanα·da / |view⊥|)` with `φ = atan2(dv, du)`. -/// `None` when the view runs down the axis (no outline) or the whole cone faces -/// toward/away (`|arg| > 1`). -fn silhouette_thetas(du: f64, dv: f64, da: f64, tan_a: f64) -> Option<(f64, f64)> { - let r_perp = (du * du + dv * dv).sqrt(); - if r_perp < 1e-6 { - return None; - } - let arg = -tan_a * da / r_perp; - if arg.abs() > 1.0 { - return None; - } - let phi = dv.atan2(du); - let delta = arg.acos(); - Some((phi + delta, phi - delta)) -}