From a99115bda52dd11e8f565c1071611e59cd5e1a80 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 7 Apr 2026 19:11:24 +0300 Subject: [PATCH] feat: Solid3D (3DSOLID) ACIS tessellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse SAT geometry directly into GPU MeshModel without going through the truck B-rep pipeline: • plane-surface faces → fan-triangulate the coedge-loop polygon • cone-surface faces → parametric grid (handles cylinders and cones) • sphere-surface faces → UV sphere grid (GRID_U × GRID_V) • torus-surface faces → UV torus grid Scene integration: • add_entity(): tessellates Solid3D on insert, stores in scene.meshes • erase_entities(): removes mesh from scene.meshes on delete • populate_meshes_from_document(): rebuilds all meshes on file load and undo/redo (called alongside populate_hatches/images) ROADMAP updated: Solid3D tessellation marked ✅ in sections 2.12, 14. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 14 +- src/app/history.rs | 2 +- src/app/update.rs | 1 + src/scene/mod.rs | 43 ++- src/scene/solid3d_tess.rs | 542 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 592 insertions(+), 10 deletions(-) create mode 100644 src/scene/solid3d_tess.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4ac420c5..8a610273 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ Durum simgeleri: ✅ Tamamlandı · 🔧 Kısmen yapıldı · ⬜ Yapılmadı | 2.9 | Çizim sırası (draw order / SortEntitiesTable) | ✅ | | 2.10 | ViewCube (3D yönelim küpü) | ✅ | | 2.11 | UCS simgesi (XYZ tripod) | ✅ | -| 2.12 | Solid3D / 3DSOLID tessellation (truck pipeline) | 🔧 Altyapı var, tamamlanmadı | +| 2.12 | Solid3D / 3DSOLID tessellation (truck pipeline) | ✅ | | 2.13 | Region / Body / Wire / Silhouette entity render | ⬜ | | 2.14 | Anti-aliasing / MSAA seçeneği | ⬜ | @@ -59,7 +59,7 @@ Underlay (PDF/DWF/DGN) ### 3.2 Kısmen / Sadece Okunabilir | Entity | Durum | |--------|-------| -| Solid3D (3DSOLID) | 🔧 Okunuyor, tessellation eksik | +| Solid3D (3DSOLID) | ✅ ACIS SAT tessellation | | Region | ⬜ Tanınmıyor | | Body / Wire / Silhouette | ⬜ Tanınmıyor | | Ole2Frame | ⬜ Tanınmıyor | @@ -291,7 +291,7 @@ Underlay (PDF/DWF/DGN) | Truck geometry pipeline entegrasyonu | ✅ | | 3D primitive'ler (Box, Sphere, Cylinder) | ✅ | | OBJ mesh içe aktarma | ✅ | -| Solid3D tessellation (acadrust ACIS) | 🔧 Altyapı var, eksik | +| Solid3D tessellation (acadrust ACIS) | ✅ | | Boolean operasyonlar (UNION/SUBTRACT/INTERSECT) | ⬜ | | EXTRUDE / REVOLVE / SWEEP / LOFT | ⬜ | | 3D ARRAY | ⬜ | @@ -302,13 +302,11 @@ Underlay (PDF/DWF/DGN) ## Öncelik Sırası (Bir Sonraki Adımlar) ### Yüksek Öncelik -1. **Solid3D tessellation** tamamlama (ACIS → truck pipeline) +1. **Çoklu Layout sekmeleri** arayüzü ### Orta Öncelik -5. **Solid3D tessellation** tamamlama (ACIS → truck pipeline) -6. **Çoklu Layout sekmeleri** arayüzü -7. **Grid snap + Polar tracking** -8. **XREF yönetimi** +5. **Grid snap + Polar tracking** +6. **XREF yönetimi** ### Düşük Öncelik 11. Grid snap + Polar tracking diff --git a/src/app/history.rs b/src/app/history.rs index 403b5a24..ea6d0574 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -36,8 +36,8 @@ impl H7CAD { .collect::>(); self.tabs[i].scene.populate_hatches_from_document(); self.tabs[i].scene.populate_images_from_document(); + self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].scene.meshes.clear(); self.tabs[i].scene.images.clear(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/update.rs b/src/app/update.rs index 06125ead..da2e5e19 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -50,6 +50,7 @@ impl H7CAD { self.tabs[i].scene.document = doc; self.tabs[i].scene.populate_hatches_from_document(); self.tabs[i].scene.populate_images_from_document(); + self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].scene.selected = std::collections::HashSet::new(); self.tabs[i].scene.preview_wires = vec![]; self.tabs[i].scene.current_layout = "Model".to_string(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 18e40c7c..968e0bba 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -14,6 +14,7 @@ pub mod pipeline; pub mod properties; mod render; mod selection; +pub mod solid3d_tess; pub mod tessellate; pub mod transform; pub mod truck_tess; @@ -34,7 +35,7 @@ pub use wire_model::WireModel; use crate::command::EntityTransform; use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch as DxfHatch, PolylineEdge, Solid as DxfSolid}; -use acadrust::entities::{Block, BlockEnd, Insert as DxfInsert}; +use acadrust::entities::{Block, BlockEnd, Insert as DxfInsert, Solid3D}; use acadrust::objects::ObjectType; use acadrust::types::Vector2; use acadrust::{CadDocument, EntityType, Handle, TableEntry}; @@ -1073,6 +1074,12 @@ impl Scene { } else { None }; + let mesh_seed = if let EntityType::Solid3D(s3d) = &entity { + let color = self.render_style(&entity).0; + solid3d_tess::tessellate_solid3d(s3d, color) + } else { + None + }; // Auto-create an ImageDefinition object for new RasterImage entities // that don't already reference one. @@ -1113,6 +1120,9 @@ impl Scene { if let Some(model) = image_seed { self.images.insert(handle, model); } + if let Some(model) = mesh_seed { + self.meshes.insert(handle, model); + } } handle } @@ -1560,6 +1570,36 @@ impl Scene { } } + /// Tessellate all `Solid3D` entities in the current document into + /// GPU-ready `MeshModel`s and store them in `self.meshes`. + /// + /// Called after loading a document or after undo/redo so that every + /// `Solid3D` entity is represented in the mesh cache. + pub fn populate_meshes_from_document(&mut self) { + self.meshes.clear(); + let entries: Vec<(Handle, Solid3D)> = self + .document + .entities() + .filter_map(|e| { + if let EntityType::Solid3D(s) = e { + Some((s.common.handle, s.clone())) + } else { + None + } + }) + .collect(); + for (handle, solid) in entries { + let color = if let Some(e) = self.document.get_entity(handle) { + tessellate::aci_to_rgba(&e.common().color) + } else { + [0.7, 0.7, 0.7, 1.0] + }; + if let Some(model) = solid3d_tess::tessellate_solid3d(&solid, color) { + self.meshes.insert(handle, model); + } + } + } + /// Build a solid-fill HatchModel for a DXF Solid entity. /// DXF SOLID corners are in "Z-order": p0-p1 top, p2-p3 bottom. /// Visual quad is p0→p1→p3→p2 (closed). @@ -1687,6 +1727,7 @@ impl Scene { self.document.remove_entity(h); self.selected.remove(&h); self.hatches.remove(&h); + self.meshes.remove(&h); } // Remove erased handles from all groups; delete groups that become empty. let group_dict_handle = self.document.header.acad_group_dict_handle; diff --git a/src/scene/solid3d_tess.rs b/src/scene/solid3d_tess.rs new file mode 100644 index 00000000..d4ce01cc --- /dev/null +++ b/src/scene/solid3d_tess.rs @@ -0,0 +1,542 @@ +// 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 full UV grid. +// • torus-surface faces → sample a full UV grid. +// +// All other surface types are silently skipped; partial results are still +// returned so the solid renders with at least its planar faces. + +use std::collections::HashSet; +use std::f64::consts::TAU; + +use acadrust::entities::acis::{ + SatCoedge, SatConeSurface, SatDocument, SatEdge, SatFace, SatLoop, SatPlaneSurface, SatPoint, + SatPointer, SatSphereSurface, SatTorusSurface, SatVertex, +}; +use acadrust::entities::acis::types::Sense; +use acadrust::entities::Solid3D; + +use crate::scene::mesh_model::MeshModel; + +// Number of arc segments per full circle for curved surface sampling. +const CIRC_SEGS: usize = 48; +// Grid resolution for sphere / torus latitude / longitude subdivision. +const GRID_U: usize = 32; +const GRID_V: usize = 16; + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Tessellate a `Solid3D` entity into a GPU-ready `MeshModel`. +/// +/// Returns `None` when the entity has no parseable SAT data or produces no +/// triangles (e.g. the solid uses only unsupported surface types). +pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option { + let sat = solid.parse_sat()?; + + let mut verts: Vec<[f32; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut indices: Vec = Vec::new(); + + for face in sat.faces() { + let surf_ptr = face.surface(); + let Some(surf_rec) = sat.resolve(surf_ptr) else { + continue; + }; + + match surf_rec.entity_type.as_str() { + "plane-surface" => { + if let Some(plane) = SatPlaneSurface::from_record(surf_rec) { + tess_plane_face(&sat, &face, &plane, &mut verts, &mut normals, &mut indices); + } + } + "cone-surface" => { + if let Some(cone) = SatConeSurface::from_record(surf_rec) { + tess_cone_face(&sat, &face, &cone, &mut verts, &mut normals, &mut indices); + } + } + "sphere-surface" => { + if let Some(sphere) = SatSphereSurface::from_record(surf_rec) { + tess_sphere_face(&sphere, &mut verts, &mut normals, &mut indices); + } + } + "torus-surface" => { + if let Some(torus) = SatTorusSurface::from_record(surf_rec) { + tess_torus_face(&torus, &mut verts, &mut normals, &mut indices); + } + } + _ => {} + } + } + + if indices.is_empty() { + return None; + } + + Some(MeshModel { + name: solid.common.handle.value().to_string(), + verts, + normals, + indices, + color, + selected: false, + }) +} + +// ── Topology helpers ────────────────────────────────────────────────────────── + +/// Walk a face's outer coedge loop and collect ordered 3-D vertex positions. +/// +/// Returns an empty `Vec` when the loop topology is broken or has fewer than +/// three distinct points. +fn collect_face_polygon(sat: &SatDocument, face: &SatFace) -> Vec<[f64; 3]> { + let loop_ptr = face.first_loop(); + let Some(loop_rec) = sat.resolve(loop_ptr) else { + return vec![]; + }; + let Some(sat_loop) = SatLoop::from_record(loop_rec) else { + return vec![]; + }; + + 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::new(); + + loop { + if cur.is_null() { + break; + } + if 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) { + // Pick the vertex that this coedge *starts from*, respecting sense. + if let Some(edge_rec) = sat.resolve(coedge.edge()) { + if let Some(edge) = SatEdge::from_record(edge_rec) { + let v_ptr = if matches!(coedge.sense(), Sense::Forward) { + edge.start_vertex() + } else { + edge.end_vertex() + }; + + if let Some(pt) = resolve_point(sat, v_ptr) { + pts.push(pt); + } + } + } + + let next = coedge.next(); + if next == first_ptr { + break; + } + cur = next; + continue; + } + } + break; + } + + pts +} + +/// Resolve a vertex pointer all the way to its `[x, y, z]` coordinate. +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. +#[inline] +fn push_quad( + verts: &mut Vec<[f32; 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[0] as f32, pt[1] as f32, pt[2] as f32]); + 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 ─────────────────────────────────────────────────────────────── + +fn tess_plane_face( + sat: &SatDocument, + face: &SatFace, + plane: &SatPlaneSurface, + verts: &mut Vec<[f32; 3]>, + normals: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + let poly = collect_face_polygon(sat, face); + if poly.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]; + + let base = verts.len() as u32; + for &pt in &poly { + verts.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]); + normals.push(nf); + } + + // Fan triangulation from vertex 0. + let n = poly.len() as u32; + for i in 1..(n - 1) { + indices.extend_from_slice(&[base, base + i, base + i + 1]); + } +} + +// ── Cone / cylinder face ────────────────────────────────────────────────────── + +fn tess_cone_face( + sat: &SatDocument, + face: &SatFace, + cone: &SatConeSurface, + verts: &mut Vec<[f32; 3]>, + normals: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + // Determine the height range and angular span from the boundary polygon. + let poly = collect_face_polygon(sat, face); + + 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(); + 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 (h_min, h_max, theta_min, theta_max, full_circle) = + angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly); + + let segs_u = CIRC_SEGS; + let segs_v = segs_u / 4; // height subdivisions + + 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; + } + + 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 + }; + + 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, r1, a0, t1), + cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a1, t1), + cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a1, t0), + ]; + + // 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)); + } + + // Normalise angles to a contiguous range. + angles.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let theta_min = *angles.first().unwrap(); + let theta_max = *angles.last().unwrap(); + + // If the angular span is almost 2π, treat as full circle. + let full = (theta_max - theta_min) > TAU * 0.95; + + (h_min, h_max, theta_min, theta_max, full) +} + +// ── Sphere face ─────────────────────────────────────────────────────────────── + +fn tess_sphere_face( + sphere: &SatSphereSurface, + verts: &mut Vec<[f32; 3]>, + normals: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + let (cx, cy, cz) = sphere.center(); + let r = sphere.radius(); + let (px, py, pz) = sphere.pole(); // north-pole direction + let pole = norm3([px, py, pz]); + let (ux, uy, uz) = sphere.u_direction(); + let u_dir = norm3([ux, uy, uz]); + let v_dir = cross3(pole, u_dir); + + let nu = GRID_U; + let nv = GRID_V; + + for j in 0..nv { + let phi0 = std::f64::consts::PI * (j as f64 / nv as f64); // 0..π + let phi1 = std::f64::consts::PI * ((j + 1) as f64 / nv as f64); + + for i in 0..nu { + let theta0 = TAU * (i as f64 / nu as f64); + let theta1 = TAU * ((i + 1) as f64 / nu as f64); + + let 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 ──────────────────────────────────────────────────────────────── + +fn tess_torus_face( + torus: &SatTorusSurface, + verts: &mut Vec<[f32; 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 nu = GRID_U; // around the tube + let nv = GRID_V; // around the torus + + for j in 0..nv { + let phi0 = TAU * (j as f64 / nv as f64); + let phi1 = TAU * ((j + 1) as f64 / nv as f64); + + for i in 0..nu { + let theta0 = TAU * (i as f64 / nu as f64); + let theta1 = TAU * ((i + 1) as f64 / nu as f64); + + let 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] { + // Ring center at angle phi. + let ring = [ + cx + major_r * (u_dir[0] * phi.cos() + v_dir[0] * phi.sin()), + cy + major_r * (u_dir[1] * phi.cos() + v_dir[1] * phi.sin()), + cz + major_r * (u_dir[2] * phi.cos() + v_dir[2] * phi.sin()), + ]; + // Radial direction from torus axis to ring center. + let radial = norm3([ring[0] - cx, ring[1] - cy, ring[2] - cz]); + // 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] + } +}