feat(solid3d): draw ISOLINES on curved faces in wireframe

A cylinder's wireframe showed only its two rim circles, so from the side it
read as two disconnected ellipses rather than a cylinder. AutoCAD draws
ISOLINES — a fixed set of longitudinal lines on each curved face — so the
surface reads from any angle.

`collect_isolines` walks the cone-surface faces (cylinder and cone), recovers
each face's height and angular extent with the same logic `tess_cone_face`
uses, and emits `header.isolines` (default 4) longitudinal segments from the
bottom rim to the top rim. They are appended to the feature-edge line list, so
they ride the existing body transform, double-single split, and top-level /
per-INSTANCE re-split for free.

Being part of `edge_verts`, they inherit the edge pass's gating: drawn in the
wireframe modes and in shaded-with-edges, hidden in plain shaded — matching
where AutoCAD shows isolines. `isolines` is threaded from the header through
the tessellation entry points alongside `facet_res`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-17 19:45:42 +03:00
1 parent 26e83a90bf
commit 0ce150411a
4 files changed
+115 -22

No files matched your search

+10 -5
View File
@@ -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<MeshLodSet> {
pub fn tessellate_volume(
e: &EntityType,
color: [f32; 4],
facet_res: f64,
isolines: usize,
) -> Option<MeshLodSet> {
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,
}
}
+95 -12
View File
@@ -141,6 +141,7 @@ fn tessellate_acis(
name: String,
color: [f32; 4],
facet_res: f64,
isolines: usize,
) -> Option<MeshLodSet> {
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<MeshLodSet> {
pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64, isolines: usize) -> Option<MeshLodSet> {
let sat = parse_acis(
|| region.parse_sat(),
region.acis_data.is_binary,
&region.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<MeshLodSet> {
pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64, isolines: usize) -> Option<MeshLodSet> {
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<MeshLodSet> {
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<MeshLodSet> {
pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64, isolines: usize) -> Option<MeshLodSet> {
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 ──────────────────────────────────────────────────────────
+8 -4
View File
@@ -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)
})
+2 -1
View File
@@ -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<Handle> = 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)
})