diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index 059340e7d..152b7fa03 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -265,12 +265,17 @@ pub fn fallback_wires(e: &EntityType) -> Option<&[acadrust::entities::Wire]> { /// Run the appropriate `solid3d_tess::tessellate_*` for the entity, /// returning `None` for non-volume entities or when the kernel fails. -pub fn tessellate_volume(e: &EntityType, color: [f32; 4], facet_res: f64) -> Option { +pub fn tessellate_volume( + e: &EntityType, + color: [f32; 4], + facet_res: f64, + isolines: usize, +) -> Option { match e { - EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color, facet_res), - EntityType::Region(r) => solid3d_tess::tessellate_region(r, color, facet_res), - EntityType::Body(b) => solid3d_tess::tessellate_body(b, color, facet_res), - EntityType::Surface(s) => solid3d_tess::tessellate_surface(s, color, facet_res), + EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color, facet_res, isolines), + EntityType::Region(r) => solid3d_tess::tessellate_region(r, color, facet_res, isolines), + EntityType::Body(b) => solid3d_tess::tessellate_body(b, color, facet_res, isolines), + EntityType::Surface(s) => solid3d_tess::tessellate_surface(s, color, facet_res, isolines), _ => None, } } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index c5b33aa7f..2da08ee71 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -141,6 +141,7 @@ fn tessellate_acis( name: String, color: [f32; 4], facet_res: f64, + isolines: usize, ) -> Option { let mut set = if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck(sat, name.clone(), color, facet_res) { @@ -158,18 +159,22 @@ fn tessellate_acis( ); fallback? }; - // Attach the B-rep face-boundary edges (body-transformed, split into the - // double-single pair) so the solid's wireframe shows real edges. - attach_feature_edges(&mut set, sat); + // 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) } /// 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) { +fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument, isolines: usize) { let xform = body_transform(sat); - let seg_pts = collect_feature_edges(sat); + 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 { @@ -187,6 +192,83 @@ fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument) { } } +/// 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. +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_rec) = sat.resolve(face.surface()) else { + continue; + }; + if surf_rec.entity_type != "cone-surface" { + continue; + } + let Some(cone) = SatConeSurface::from_record(surf_rec) else { + continue; + }; + let (cx, cy, cz) = cone.center(); + let (ax, ay, az) = cone.axis(); + let (ux, uy, uz) = cone.major_axis(); + let radius = cone.radius(); + 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); + + // Same face-extent recovery as `tess_cone_face`: read the height and + // angular span from the boundary, and when the boundary is a single + // closed rim (no height span), recover the top/bottom from the coaxial + // circle rims. + let poly = collect_face_polygon(sat, &face, 48); + 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); + if (h_max - h_min).abs() < 1e-9 { + if let Some((vmin, vmax)) = cone_axis_span(sat, &cone, axis, [cx, cy, cz]) { + h_min = vmin; + h_max = vmax; + if full_circle { + theta_min = 0.0; + theta_max = TAU; + } + } + } + let theta_span = if full_circle { TAU } else { theta_max - theta_min }; + if (h_max - h_min).abs() < 1e-10 || theta_span.abs() < 1e-10 { + continue; + } + let r_at = |h: f64| { + if cos_a.abs() > 1e-9 { + radius + h * sin_a / cos_a + } else { + radius + } + }; + let (r0, r1) = (r_at(h_min), r_at(h_max)); + // A closed rim's line at theta and theta+TAU coincide, so step across + // [0, TAU) with `count` divisions; a bounded arc gets interior lines + // spanning its own arc (its two ends are already drawn as rim edges). + for k in 0..count { + let a = if full_circle { + theta_min + theta_span * (k as f64 / count as f64) + } else { + theta_min + theta_span * ((k as f64 + 1.0) / (count as f64 + 1.0)) + }; + out.push(cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a, h_min)); + out.push(cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a, h_max)); + } + } + 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). @@ -378,25 +460,25 @@ fn parse_acis( } /// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels. -pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64) -> Option { +pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { let sat = parse_acis( || region.parse_sat(), region.acis_data.is_binary, ®ion.acis_data.sab_data, )?; let name = region.common.handle.value().to_string(); - tessellate_acis(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res, isolines) } /// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels. -pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64) -> Option { +pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { let sat = parse_acis( || body.parse_sat(), body.acis_data.is_binary, &body.acis_data.sab_data, )?; let name = body.common.handle.value().to_string(); - tessellate_acis(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res, isolines) } /// Tessellate a `Surface` entity (ACAD_SURFACE family) at all three LOD @@ -407,6 +489,7 @@ pub fn tessellate_surface( surface: &acadrust::entities::Surface, color: [f32; 4], facet_res: f64, + isolines: usize, ) -> Option { let sat = parse_acis( || surface.parse_sat(), @@ -414,7 +497,7 @@ pub fn tessellate_surface( &surface.acis_data.sab_data, )?; let name = surface.common.handle.value().to_string(); - tessellate_acis(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res, isolines) } /// Tessellate a `Solid3D` entity at all three LOD levels. @@ -422,14 +505,14 @@ pub fn tessellate_surface( /// Returns `None` when the entity has no parseable SAT data or produces no /// triangles (e.g. the solid uses only unsupported surface types). /// `facet_res` mirrors the header FACETRES variable (0.01–10.0). -pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64) -> Option { +pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64, isolines: usize) -> Option { let sat = parse_acis( || solid.parse_sat(), solid.acis_data.is_binary, &solid.acis_data.sab_data, )?; let name = solid.common.handle.value().to_string(); - tessellate_acis(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res, isolines) } // ── Topology helpers ────────────────────────────────────────────────────────── diff --git a/src/scene/entity.rs b/src/scene/entity.rs index edbf65459..2f4bdcca8 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -71,12 +71,13 @@ impl Scene { None }; let facet_res = self.document.header.facet_resolution; + let isolines = self.document.header.isolines.max(0) as usize; let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) ) { let color = self.render_style(&entity).0; - crate::entities::solid3d::tessellate_volume(&entity, color, facet_res) + crate::entities::solid3d::tessellate_volume(&entity, color, facet_res, isolines) .map(|m| offset_mesh_lod_set(m)) } else { None @@ -247,12 +248,13 @@ impl Scene { None }; let facet_res = self.document.header.facet_resolution; + let isolines = self.document.header.isolines.max(0) as usize; let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) ) { let color = self.render_style(&entity).0; - crate::entities::solid3d::tessellate_volume(&entity, color, facet_res) + crate::entities::solid3d::tessellate_volume(&entity, color, facet_res, isolines) .map(|m| offset_mesh_lod_set(m)) } else { None @@ -327,6 +329,7 @@ impl Scene { None }; let facet_res = self.document.header.facet_resolution; + let isolines = self.document.header.isolines.max(0) as usize; let mesh_seed = if matches!( entity, EntityType::Solid3D(_) @@ -335,7 +338,7 @@ impl Scene { | EntityType::Surface(_) ) { let color = self.render_style(entity).0; - crate::entities::solid3d::tessellate_volume(entity, color, facet_res) + crate::entities::solid3d::tessellate_volume(entity, color, facet_res, isolines) .map(offset_mesh_lod_set) } else { None @@ -1705,13 +1708,14 @@ impl Scene { use crate::par::prelude::*; let facet_res = self.document.header.facet_resolution; + let isolines = self.document.header.isolines.max(0) as usize; // Top-level solids: offset into the render frame, drawn flat. // Block-definition solids: keep block-local coords for per-INSERT // instancing (no offset applied here). let built: Vec<(Handle, MeshLodSet, bool)> = entries .into_par_iter() .filter_map(|(handle, entity, color, top_level)| { - crate::entities::solid3d::tessellate_volume(&entity, color, facet_res).map(|m| { + crate::entities::solid3d::tessellate_volume(&entity, color, facet_res, isolines).map(|m| { let m = if top_level { offset_mesh_lod_set(m) } else { m }; (handle, m, top_level) }) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index fe8c7539e..ddde37f05 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -341,6 +341,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { // Top-level (layout-owned) solids are offset into the render frame; block // definition solids keep block-local coords for per-INSERT instancing. (#123) let facet_res = doc.header.facet_resolution; + let isolines = doc.header.isolines.max(0) as usize; // Real layout blocks come from the Layout objects' block_record handles — // `BlockRecord::is_layout()` is unreliable here (it flags ordinary blocks). let layout_blocks: std::collections::HashSet = doc @@ -360,7 +361,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { let (raw, ..) = view::render::render_style_for(doc, e); let color = view::render::adapt_to_bg(raw, LOAD_BG); let top_level = layout_blocks.contains(&e.common().owner_handle); - crate::entities::solid3d::tessellate_volume(e, color, facet_res).map(|m| { + crate::entities::solid3d::tessellate_volume(e, color, facet_res, isolines).map(|m| { let m = if top_level { offset_mesh_lod_set(m) } else { m }; (handle, m, top_level) })