diff --git a/.gitignore b/.gitignore index 56a44e8..3264c83 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ target/ # Compiled Python extension (produced by maturin develop) *.so *.pyd +*.pdb # Python bytecode __pycache__/ diff --git a/crates/arcanum-geometry/src/discretize.rs b/crates/arcanum-geometry/src/discretize.rs new file mode 100644 index 0000000..bb348fa --- /dev/null +++ b/crates/arcanum-geometry/src/discretize.rs @@ -0,0 +1,188 @@ +// discretize.rs — Wire discretization into segments (Steps 2–4) + +use std::f64::consts::PI; + +use arcanum_nec_import::{ArcWire, HelixWire, StraightWire, WireDescription}; +use nalgebra::Vector3; + +use crate::errors::{GeometryError, GeometryErrorKind, GeometryWarnings}; +use crate::mesh::{ArcParams, CurveParams, HelixParams, LinearParams, Material, Segment, TagMap}; + +pub(crate) fn discretize_wires( + wires: &[WireDescription], + _warnings: &mut GeometryWarnings, +) -> Result<(Vec, TagMap), GeometryError> { + let mut segments: Vec = Vec::new(); + let mut tag_map = TagMap::new(); + + for (wire_index, wire) in wires.iter().enumerate() { + let first = segments.len(); + match wire { + WireDescription::Straight(w) => discretize_straight(w, wire_index, &mut segments)?, + WireDescription::Arc(w) => discretize_arc(w, wire_index, &mut segments)?, + WireDescription::Helix(w) => discretize_helix(w, wire_index, &mut segments)?, + } + let last = segments.len() - 1; + tag_map.insert(wire.tag(), first, last); + } + + Ok((segments, tag_map)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step 2 — Linear (GW) +// ───────────────────────────────────────────────────────────────────────────── + +fn discretize_straight( + wire: &StraightWire, + wire_index: usize, + segments: &mut Vec, +) -> Result<(), GeometryError> { + let n = wire.segment_count as usize; + + if n == 0 { + return Err(GeometryError::new( + GeometryErrorKind::ZeroSegmentCount, + wire_index, + format!("GW tag={} has segment count 0", wire.tag), + )); + } + + let r_a = Vector3::new(wire.x1, wire.y1, wire.z1); + let r_b = Vector3::new(wire.x2, wire.y2, wire.z2); + + if (r_b - r_a).norm() == 0.0 { + return Err(GeometryError::new( + GeometryErrorKind::ZeroLengthWire, + wire_index, + format!( + "GW tag={} has identical start and end coordinates", + wire.tag + ), + )); + } + + let base_index = segments.len(); + for k in 0..n { + // Evaluate endpoints from closed-form — never accumulate incrementally. + let t0 = k as f64 / n as f64; + let t1 = (k + 1) as f64 / n as f64; + let start = r_a + t0 * (r_b - r_a); + let end = r_a + t1 * (r_b - r_a); + + segments.push(Segment { + curve: CurveParams::Linear(LinearParams { start, end }), + wire_radius: wire.radius, + material: Material::PEC, + tag: wire.tag, + segment_index: base_index + k, + wire_index, + is_image: false, + }); + } + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step 3 — Arc (GA) +// ───────────────────────────────────────────────────────────────────────────── + +fn discretize_arc( + wire: &ArcWire, + wire_index: usize, + segments: &mut Vec, +) -> Result<(), GeometryError> { + let n = wire.segment_count as usize; + let theta1 = wire.angle1.to_radians(); + let theta2 = wire.angle2.to_radians(); + let r = wire.arc_radius; + + // Evaluate arc point in XZ plane: r(θ) = (R cosθ, 0, R sinθ) + let arc_point = + |theta: f64| -> Vector3 { Vector3::new(r * theta.cos(), 0.0, r * theta.sin()) }; + + let base_index = segments.len(); + for k in 0..n { + // Closed-form angle bounds for segment k. + let t0 = k as f64 / n as f64; + let t1 = (k + 1) as f64 / n as f64; + let th1k = theta1 + t0 * (theta2 - theta1); + let th2k = theta1 + t1 * (theta2 - theta1); + + let start = arc_point(th1k); + let end = arc_point(th2k); + + segments.push(Segment { + curve: CurveParams::Arc(ArcParams { + radius: r, + theta1: th1k, + theta2: th2k, + start, + end, + }), + wire_radius: wire.radius, + material: Material::PEC, + tag: wire.tag, + segment_index: base_index + k, + wire_index, + is_image: false, + }); + } + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step 4 — Helix (GH) +// ───────────────────────────────────────────────────────────────────────────── + +fn discretize_helix( + wire: &HelixWire, + wire_index: usize, + segments: &mut Vec, +) -> Result<(), GeometryError> { + let n = wire.segment_count as usize; + let a1 = wire.radius_start; + let a2 = wire.radius_end; + let hl = wire.total_length; + let n_turns = wire.n_turns; + + // Radius at parameter t ∈ [0,1] of the full helix. + let radius_at = |t: f64| -> f64 { a1 + t * (a2 - a1) }; + + // Helix position at parameter t ∈ [0,1]: + // r(t) = (A(t) cos(2π N t), A(t) sin(2π N t), HL·t) + let helix_point = |t: f64| -> Vector3 { + let a = radius_at(t); + let angle = 2.0 * PI * n_turns * t; + Vector3::new(a * angle.cos(), a * angle.sin(), hl * t) + }; + + let base_index = segments.len(); + for k in 0..n { + // Closed-form parameter values — never accumulated. + let t0 = k as f64 / n as f64; + let t1 = (k + 1) as f64 / n as f64; + let start = helix_point(t0); + let end = helix_point(t1); + + segments.push(Segment { + curve: CurveParams::Helix(HelixParams { + radius_start: a1, + radius_end: a2, + total_length: hl, + n_turns, + n_segments: n as u32, + segment_index: k as u32, + start, + end, + }), + wire_radius: wire.radius, + material: Material::PEC, + tag: wire.tag, + segment_index: base_index + k, + wire_index, + is_image: false, + }); + } + Ok(()) +} diff --git a/crates/arcanum-geometry/src/errors.rs b/crates/arcanum-geometry/src/errors.rs new file mode 100644 index 0000000..154806c --- /dev/null +++ b/crates/arcanum-geometry/src/errors.rs @@ -0,0 +1,122 @@ +// errors.rs — Phase 1 error and warning types + +/// A hard error that aborts mesh construction. +#[derive(Debug, Clone)] +pub struct GeometryError { + pub kind: GeometryErrorKind, + /// 1-based index into the wire list (not a line number — Phase 1 works + /// with already-parsed WireDescriptions). + pub wire_index: usize, + pub message: String, +} + +impl GeometryError { + pub fn new(kind: GeometryErrorKind, wire_index: usize, message: impl Into) -> Self { + GeometryError { + kind, + wire_index, + message: message.into(), + } + } +} + +impl std::fmt::Display for GeometryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[wire {}] {}: {}", + self.wire_index, + self.kind.as_str(), + self.message + ) + } +} + +/// Category of a hard geometry error. +#[derive(Debug, Clone, PartialEq)] +pub enum GeometryErrorKind { + /// Wire has zero length (start == end). + ZeroLengthWire, + /// Segment count is zero (should have been caught by nec-import, but + /// checked here defensively). + ZeroSegmentCount, + /// A GM operation references a tag that does not exist in the wire list. + UnknownTagReference, + /// A GM copy operation would generate a duplicate tag. + DuplicateTag, + /// A coordinate is NaN or infinite. + InvalidCoordinate, +} + +impl GeometryErrorKind { + pub fn as_str(&self) -> &'static str { + match self { + GeometryErrorKind::ZeroLengthWire => "ZeroLengthWire", + GeometryErrorKind::ZeroSegmentCount => "ZeroSegmentCount", + GeometryErrorKind::UnknownTagReference => "UnknownTagReference", + GeometryErrorKind::DuplicateTag => "DuplicateTag", + GeometryErrorKind::InvalidCoordinate => "InvalidCoordinate", + } + } +} + +/// A non-fatal condition worth reporting. +#[derive(Debug, Clone)] +pub struct GeometryWarning { + pub kind: GeometryWarningKind, + pub message: String, +} + +impl GeometryWarning { + pub fn new(kind: GeometryWarningKind, message: impl Into) -> Self { + GeometryWarning { + kind, + message: message.into(), + } + } +} + +/// Category of a geometry warning. +#[derive(Debug, Clone, PartialEq)] +pub enum GeometryWarningKind { + /// Two wire endpoints are closer than 10ε but farther than ε — possible + /// modeling error. + NearCoincidentEndpoints, + /// A wire lies entirely in the z = 0 ground plane; no image generated. + WireInGroundPlane, +} + +impl GeometryWarningKind { + pub fn as_str(&self) -> &'static str { + match self { + GeometryWarningKind::NearCoincidentEndpoints => "NearCoincidentEndpoints", + GeometryWarningKind::WireInGroundPlane => "WireInGroundPlane", + } + } +} + +/// Accumulated list of geometry warnings. +#[derive(Debug, Clone, Default)] +pub struct GeometryWarnings(Vec); + +impl GeometryWarnings { + pub fn new() -> Self { + GeometryWarnings::default() + } + + pub fn push(&mut self, w: GeometryWarning) { + self.0.push(w); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn into_vec(self) -> Vec { + self.0 + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} diff --git a/crates/arcanum-geometry/src/images.rs b/crates/arcanum-geometry/src/images.rs new file mode 100644 index 0000000..7838834 --- /dev/null +++ b/crates/arcanum-geometry/src/images.rs @@ -0,0 +1,93 @@ +// images.rs — PEC ground plane image generation (Step 7) +// +// For each real segment not lying in the z = 0 plane: +// - Create an image segment with z-coordinates negated +// - Image segments are flagged is_image = true +// - Image segments are appended after all real segments +// +// Wires entirely in the z = 0 plane produce no image; a warning is emitted. + +use crate::errors::{GeometryWarning, GeometryWarningKind, GeometryWarnings}; +use crate::mesh::{ArcParams, CurveParams, HelixParams, LinearParams, Segment}; + +pub(crate) fn generate(segments: &mut Vec, warnings: &mut GeometryWarnings) { + let real_count = segments.len(); + let mut image_segs: Vec = Vec::new(); + + for seg in segments[..real_count].iter() { + let start_z = seg.start().z; + let end_z = seg.end().z; + + // Detect wire entirely in the z = 0 ground plane. + if start_z == 0.0 && end_z == 0.0 { + warnings.push(GeometryWarning::new( + GeometryWarningKind::WireInGroundPlane, + format!( + "segment {} (tag={}) lies in z=0 ground plane; no image generated", + seg.segment_index, seg.tag + ), + )); + continue; + } + + // Build image segment: negate z-coordinates. + let image_curve = reflect_z(&seg.curve); + let base_index = real_count + image_segs.len(); + + image_segs.push(Segment { + curve: image_curve, + wire_radius: seg.wire_radius, + material: seg.material.clone(), + tag: seg.tag, + segment_index: base_index, + wire_index: seg.wire_index, + is_image: true, + }); + } + + segments.extend(image_segs); +} + +/// Reflect a CurveParams through the z = 0 plane: z → -z. +fn reflect_z(curve: &CurveParams) -> CurveParams { + match curve { + CurveParams::Linear(p) => { + let mut start = p.start; + let mut end = p.end; + start.z = -start.z; + end.z = -end.z; + CurveParams::Linear(LinearParams { start, end }) + } + CurveParams::Arc(p) => { + let mut start = p.start; + let mut end = p.end; + start.z = -start.z; + end.z = -end.z; + // theta1/theta2 are now in the negated-z plane (reflected arc). + // Phase 2 uses the Cartesian endpoints; angles are approximate. + CurveParams::Arc(ArcParams { + radius: p.radius, + theta1: -p.theta1, + theta2: -p.theta2, + start, + end, + }) + } + CurveParams::Helix(p) => { + let mut start = p.start; + let mut end = p.end; + start.z = -start.z; + end.z = -end.z; + CurveParams::Helix(HelixParams { + radius_start: p.radius_start, + radius_end: p.radius_end, + total_length: p.total_length, + n_turns: p.n_turns, + n_segments: p.n_segments, + segment_index: p.segment_index, + start, + end, + }) + } + } +} diff --git a/crates/arcanum-geometry/src/junctions.rs b/crates/arcanum-geometry/src/junctions.rs new file mode 100644 index 0000000..e407584 --- /dev/null +++ b/crates/arcanum-geometry/src/junctions.rs @@ -0,0 +1,161 @@ +// junctions.rs — Junction detection (Step 6) +// +// Algorithm: +// 1. Enumerate all 2N segment endpoints. +// 2. For each pair (i, j) compute distance d. +// ε = min(radius_i, radius_j) × 0.01 +// d < ε → merge into same junction +// ε ≤ d < 10ε → near-coincident warning (no junction) +// 3. Collect merged groups into Junction records. +// 4. Build bidirectional endpoint_junction map. +// +// A junction is flagged is_self_loop when its endpoints all belong to the +// same wire AND the group spans at least one Start-End pair from that wire. + +use crate::errors::{GeometryWarning, GeometryWarningKind, GeometryWarnings}; +use crate::mesh::{EndpointSide, Junction, Segment, SegmentEndpoint}; +use nalgebra::Vector3; + +pub(crate) fn detect( + segments: &[Segment], + warnings: &mut GeometryWarnings, +) -> (Vec, Vec>) { + let n = segments.len(); + if n == 0 { + return (Vec::new(), Vec::new()); + } + + let endpoint_count = 2 * n; + + // Endpoint index encoding: seg_idx * 2 + 0 = Start, seg_idx * 2 + 1 = End. + let ep_point = |ep_idx: usize| -> Vector3 { + let seg_idx = ep_idx / 2; + if ep_idx.is_multiple_of(2) { + segments[seg_idx].start() + } else { + segments[seg_idx].end() + } + }; + let ep_radius = |ep_idx: usize| -> f64 { segments[ep_idx / 2].wire_radius }; + + // Union-find over endpoints. + let mut parent: Vec = (0..endpoint_count).collect(); + let find = |parent: &mut Vec, mut x: usize| -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; // path compression + x = parent[x]; + } + x + }; + + // Scan all pairs, merge coincident endpoints, warn on near-coincident. + for i in 0..endpoint_count { + let pi = ep_point(i); + let ri = ep_radius(i); + for j in (i + 1)..endpoint_count { + // Skip intra-wire adjacent pairs: within the same wire, consecutive + // segment end→start connections are implicit and never junctions. + let si = i / 2; + let sj = j / 2; + if segments[si].wire_index == segments[sj].wire_index { + // Check adjacency: (si End, sj Start) with sj == si + 1. + let i_is_end = i % 2 == 1; + let j_is_start = j % 2 == 0; + if i_is_end && j_is_start && sj == si + 1 { + continue; + } + // Symmetric: (sj End, si Start) with si == sj + 1 (won't occur + // since i < j, but kept for clarity). + } + + let pj = ep_point(j); + let rj = ep_radius(j); + let eps = ri.min(rj) * 0.01; + let dist = (pi - pj).norm(); + + if dist < eps { + // Merge i and j. + let ri_root = find(&mut parent, i); + let rj_root = find(&mut parent, j); + if ri_root != rj_root { + parent[rj_root] = ri_root; + } + } else if dist < 10.0 * eps { + // Near-coincident — emit warning but do NOT merge. + let si = i / 2; + let sj = j / 2; + let side_i = if i % 2 == 0 { "start" } else { "end" }; + let side_j = if j % 2 == 0 { "start" } else { "end" }; + warnings.push(GeometryWarning::new( + GeometryWarningKind::NearCoincidentEndpoints, + format!( + "near-coincident endpoints: seg {} {} ({:.4},{:.4},{:.4}) and \ + seg {} {} ({:.4},{:.4},{:.4}), gap = {:.3e} m", + si, side_i, pi.x, pi.y, pi.z, sj, side_j, pj.x, pj.y, pj.z, dist + ), + )); + } + } + } + + // Canonicalize parents (flatten path compression). + for i in 0..endpoint_count { + let root = find(&mut parent, i); + parent[i] = root; + } + + // Group endpoints by root. + // Only form a Junction if the group has more than one endpoint. + let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, &root) in parent.iter().enumerate().take(endpoint_count) { + groups.entry(root).or_default().push(i); + } + + // Build junctions and endpoint_junction. + let mut junctions: Vec = Vec::new(); + let mut endpoint_junction: Vec> = vec![None; endpoint_count]; + + for members in groups.values() { + if members.len() < 2 { + // Single endpoint — free end, not a junction. + continue; + } + + let junction_index = junctions.len(); + + // Build endpoint records. + let endpoints: Vec = members + .iter() + .map(|&ep_idx| SegmentEndpoint { + segment_index: ep_idx / 2, + side: if ep_idx % 2 == 0 { + EndpointSide::Start + } else { + EndpointSide::End + }, + }) + .collect(); + + // Self-loop detection: all endpoints belong to the same wire, and the + // group includes at least one Start and one End from that wire. + let wire_indices: std::collections::HashSet = members + .iter() + .map(|&ep_idx| segments[ep_idx / 2].wire_index) + .collect(); + let sides: std::collections::HashSet = + members.iter().map(|&ep_idx| (ep_idx % 2) as u8).collect(); + let is_self_loop = wire_indices.len() == 1 && sides.len() == 2; + + junctions.push(Junction { + junction_index, + endpoints, + is_self_loop, + }); + + for &ep_idx in members { + endpoint_junction[ep_idx] = Some(junction_index); + } + } + + (junctions, endpoint_junction) +} diff --git a/crates/arcanum-geometry/src/lib.rs b/crates/arcanum-geometry/src/lib.rs index e60ac73..8e28108 100644 --- a/crates/arcanum-geometry/src/lib.rs +++ b/crates/arcanum-geometry/src/lib.rs @@ -1,8 +1,106 @@ // arcanum-geometry — Phase 1: Geometry Discretization // -// Accepts MeshInput from the NEC parser and produces a segment mesh: -// a set of conformal cylindrical segments with geometry, connectivity, -// and source/load maps. +// Accepts MeshInput (and the associated GroundElectrical) from arcanum-nec-import +// and produces a Mesh: a complete, validated segment mesh with connectivity and +// ground descriptor. // -// Stub. Implementation begins after nec-import is complete and -// docs/phase1-geometry/design.md is approved. +// Public API: +// pub fn build_mesh( +// input: MeshInput, +// ground_electrical: Option, +// ) -> Result<(Mesh, GeometryWarnings), GeometryError> + +pub mod errors; +pub mod mesh; + +pub(crate) mod discretize; +pub(crate) mod images; +pub(crate) mod junctions; +pub(crate) mod tagmap; +pub(crate) mod transforms; + +#[cfg(test)] +mod tests; + +use arcanum_nec_import::{GroundElectrical, GroundType as NecGroundType, MeshInput}; + +pub use errors::{ + GeometryError, GeometryErrorKind, GeometryWarning, GeometryWarningKind, GeometryWarnings, +}; +pub use mesh::{ + ArcParams, CurveParams, CurveType, EndpointSide, GroundDescriptor, GroundType, HelixParams, + Junction, LinearParams, Material, Mesh, Segment, SegmentEndpoint, TagMap, +}; + +/// Build a segment mesh from a parsed NEC MeshInput. +/// +/// `ground_electrical` carries the lossy ground parameters from the GN card +/// (conductivity and permittivity). Phase 1 stores them in the GroundDescriptor +/// for Phase 2 to consume; Phase 1 itself does not use them. +/// +/// Returns `(Mesh, GeometryWarnings)` on success. Returns `GeometryError` on +/// any hard error. +pub fn build_mesh( + input: MeshInput, + ground_electrical: Option, +) -> Result<(Mesh, GeometryWarnings), GeometryError> { + let mut warnings = GeometryWarnings::new(); + + // Step 1: Discretize all declared wires into segments. + let (mut segments, mut tag_map) = discretize::discretize_wires(&input.wires, &mut warnings)?; + + // Step 2: Apply GS scale and GM transformations. + transforms::apply(&mut segments, &mut tag_map, &input.transforms, &input.wires)?; + + // Step 3: Build the ground descriptor. + let mut ground = build_ground_descriptor(&input, ground_electrical); + + // Step 4: Generate PEC image segments if required. + if ground.ground_type == GroundType::PEC { + images::generate(&mut segments, &mut warnings); + ground.images_generated = true; + } + + // Step 5: Detect junctions. + let (junctions, endpoint_junction) = junctions::detect(&segments, &mut warnings); + + Ok(( + Mesh { + segments, + junctions, + endpoint_junction, + ground, + tag_map, + }, + warnings, + )) +} + +fn build_ground_descriptor( + input: &MeshInput, + ground_electrical: Option, +) -> GroundDescriptor { + let ground_type = match input.ground.ground_type { + NecGroundType::PEC => GroundType::PEC, + NecGroundType::Lossy | NecGroundType::Sommerfeld => GroundType::Lossy, + NecGroundType::FreeSpace => GroundType::None, + }; + + let (conductivity, permittivity) = match ground_type { + GroundType::Lossy => { + if let Some(ge) = ground_electrical { + (Some(ge.conductivity), Some(ge.permittivity)) + } else { + (None, None) + } + } + _ => (None, None), + }; + + GroundDescriptor { + ground_type, + conductivity, + permittivity, + images_generated: false, + } +} diff --git a/crates/arcanum-geometry/src/mesh.rs b/crates/arcanum-geometry/src/mesh.rs new file mode 100644 index 0000000..33f927f --- /dev/null +++ b/crates/arcanum-geometry/src/mesh.rs @@ -0,0 +1,306 @@ +// mesh.rs — Phase 1 output types +// +// The Mesh struct is the sole output of Phase 1 and the sole geometric +// input to all subsequent phases. It is immutable after Phase 1 completes. + +use nalgebra::Vector3; + +// ───────────────────────────────────────────────────────────────────────────── +// Curve representation +// ───────────────────────────────────────────────────────────────────────────── + +/// The parametric curve type of a segment. +#[derive(Debug, Clone, PartialEq)] +pub enum CurveType { + Linear, + Arc, + Helix, +} + +/// Parametric description of a segment's geometry. +/// +/// For each type, the segment is parameterized by σ ∈ [0, 1] where σ = 0 is +/// the segment start and σ = 1 is the segment end. Phase 2 evaluates r(σ), +/// r'(σ), and |r'(σ)| at Gauss-Legendre quadrature points. +#[derive(Debug, Clone)] +pub enum CurveParams { + /// Straight wire segment. + Linear(LinearParams), + /// Circular arc segment in the XZ plane (or rotated via GM). + Arc(ArcParams), + /// Helical segment about the z-axis. + Helix(HelixParams), +} + +/// Parameters for a linear segment: r(σ) = start + σ(end − start). +#[derive(Debug, Clone)] +pub struct LinearParams { + pub start: Vector3, + pub end: Vector3, +} + +impl LinearParams { + /// Length of the segment. + pub fn length(&self) -> f64 { + (self.end - self.start).norm() + } +} + +/// Parameters for an arc segment. +/// +/// r(σ) = (R cos θ(σ), 0, R sin θ(σ)) where θ(σ) = θ1 + σ(θ2 − θ1). +/// θ1 and θ2 are in radians. After any GM rotation the Cartesian endpoints +/// are stored directly; Phase 2 uses those. +#[derive(Debug, Clone)] +pub struct ArcParams { + /// Arc radius (meters). + pub radius: f64, + /// Start angle of this segment (radians). + pub theta1: f64, + /// End angle of this segment (radians). + pub theta2: f64, + /// Precomputed start point (for junction detection and image generation). + pub start: Vector3, + /// Precomputed end point. + pub end: Vector3, +} + +/// Parameters for a helical segment. +/// +/// r(σ) = (A(τ) cos(2π N τ), A(τ) sin(2π N τ), HL τ) where τ = k/N_seg + σ/N_seg. +#[derive(Debug, Clone)] +pub struct HelixParams { + /// Radius at the start of the full helix (A₁). + pub radius_start: f64, + /// Radius at the end of the full helix (A₂). + pub radius_end: f64, + /// Total axial length of the full helix. + pub total_length: f64, + /// Total number of turns of the full helix. + pub n_turns: f64, + /// Total number of segments the full helix is divided into. + pub n_segments: u32, + /// Index of this segment within the full helix (0-based). + pub segment_index: u32, + /// Precomputed start point. + pub start: Vector3, + /// Precomputed end point. + pub end: Vector3, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Material +// ───────────────────────────────────────────────────────────────────────────── + +/// Wire material model. +#[derive(Debug, Clone, Default, PartialEq)] +pub enum Material { + /// Perfect electric conductor (default). + #[default] + PEC, + /// Finite conductivity wire. + Lossy { conductivity: f64 }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Segment +// ───────────────────────────────────────────────────────────────────────────── + +/// A single discretized wire segment. +/// +/// Segments are the fundamental unit of the mesh. Phase 2 integrates over +/// each segment to fill the impedance matrix. +#[derive(Debug, Clone)] +pub struct Segment { + /// Parametric curve description for Phase 2 integration. + pub curve: CurveParams, + /// Wire cross-section radius (meters). Not scaled by GS. + pub wire_radius: f64, + /// Material model. + pub material: Material, + /// NEC wire tag number. Image segments use the tag of their source wire. + pub tag: u32, + /// Global index of this segment in the mesh segment list. + pub segment_index: usize, + /// Index of the wire (GW/GA/GH card) this segment belongs to. + pub wire_index: usize, + /// True if this is a PEC ground image segment (not addressable by EX/LD). + pub is_image: bool, +} + +impl Segment { + /// Precomputed start point of the segment. + pub fn start(&self) -> Vector3 { + match &self.curve { + CurveParams::Linear(p) => p.start, + CurveParams::Arc(p) => p.start, + CurveParams::Helix(p) => p.start, + } + } + + /// Precomputed end point of the segment. + pub fn end(&self) -> Vector3 { + match &self.curve { + CurveParams::Linear(p) => p.end, + CurveParams::Arc(p) => p.end, + CurveParams::Helix(p) => p.end, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Junction +// ───────────────────────────────────────────────────────────────────────────── + +/// Which end of a segment is at a junction. +#[derive(Debug, Clone, PartialEq)] +pub enum EndpointSide { + Start, + End, +} + +/// A reference to one endpoint of one segment. +#[derive(Debug, Clone)] +pub struct SegmentEndpoint { + pub segment_index: usize, + pub side: EndpointSide, +} + +/// A point in space where two or more segment endpoints meet. +#[derive(Debug, Clone)] +pub struct Junction { + /// Unique index of this junction. + pub junction_index: usize, + /// All segment endpoints at this junction. + pub endpoints: Vec, + /// True if this junction is a self-loop (start and end of the same wire). + pub is_self_loop: bool, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ground descriptor +// ───────────────────────────────────────────────────────────────────────────── + +/// Ground plane model passed through to Phase 2. +#[derive(Debug, Clone)] +pub struct GroundDescriptor { + pub ground_type: GroundType, + /// σ (S/m). None for PEC or free space. + pub conductivity: Option, + /// εᵣ (relative). None for PEC or free space. + pub permittivity: Option, + /// True if Phase 1 added image segments to the mesh. + pub images_generated: bool, +} + +/// Ground type, derived from the GN card IPERF field. +#[derive(Debug, Clone, PartialEq)] +pub enum GroundType { + /// No GN card. Free space. + None, + /// IPERF = 0 or 2. Lossy ground; no image segments. + Lossy, + /// IPERF = 1. PEC; image segments generated by Phase 1. + PEC, +} + +impl Default for GroundDescriptor { + fn default() -> Self { + GroundDescriptor { + ground_type: GroundType::None, + conductivity: None, + permittivity: None, + images_generated: false, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tag map +// ───────────────────────────────────────────────────────────────────────────── + +/// Maps NEC wire tag numbers to segment index ranges in the mesh. +/// +/// Image segments are excluded — they are not addressable by EX/LD cards. +#[derive(Debug, Clone, Default)] +pub struct TagMap { + entries: Vec, +} + +#[derive(Debug, Clone)] +struct TagEntry { + tag: u32, + /// First segment index for this wire (inclusive). + first: usize, + /// Last segment index for this wire (inclusive). + last: usize, +} + +impl TagMap { + pub fn new() -> Self { + TagMap::default() + } + + /// Register a wire's segment range. + pub fn insert(&mut self, tag: u32, first: usize, last: usize) { + self.entries.push(TagEntry { tag, first, last }); + } + + /// Look up the segment index range for a tag. Returns None if not found. + pub fn get(&self, tag: u32) -> Option<(usize, usize)> { + self.entries + .iter() + .find(|e| e.tag == tag) + .map(|e| (e.first, e.last)) + } + + /// Number of segments for a given tag. Returns None if not found. + pub fn segment_count(&self, tag: u32) -> Option { + self.get(tag).map(|(first, last)| last - first + 1) + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.entries.iter().map(|e| (e.tag, e.first, e.last)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mesh — the Phase 1 output +// ───────────────────────────────────────────────────────────────────────────── + +/// The complete discretized segment mesh produced by Phase 1. +/// +/// Immutable after construction. All downstream phases consume this struct. +#[derive(Debug)] +pub struct Mesh { + /// All segments in order: real segments first, image segments last. + pub segments: Vec, + /// All detected junctions. + pub junctions: Vec, + /// Bidirectional endpoint → junction index map. + /// Indexed as `endpoint_junction[segment_index * 2 + side]` + /// where side 0 = Start, side 1 = End. `None` means free endpoint. + pub endpoint_junction: Vec>, + /// Ground plane descriptor. + pub ground: GroundDescriptor, + /// Tag → segment index range (real segments only). + pub tag_map: TagMap, +} + +impl Mesh { + /// Number of real (non-image) segments. + pub fn real_segment_count(&self) -> usize { + self.segments.iter().filter(|s| !s.is_image).count() + } + + /// Number of image segments. + pub fn image_segment_count(&self) -> usize { + self.segments.iter().filter(|s| s.is_image).count() + } + + /// Look up which junction (if any) a segment endpoint belongs to. + pub fn junction_at(&self, segment_index: usize, side: &EndpointSide) -> Option { + let idx = segment_index * 2 + if *side == EndpointSide::Start { 0 } else { 1 }; + self.endpoint_junction.get(idx).copied().flatten() + } +} diff --git a/crates/arcanum-geometry/src/tagmap.rs b/crates/arcanum-geometry/src/tagmap.rs new file mode 100644 index 0000000..04adab1 --- /dev/null +++ b/crates/arcanum-geometry/src/tagmap.rs @@ -0,0 +1,4 @@ +// tagmap.rs — Tag map construction helpers (Step 8) +// +// The TagMap itself is defined in mesh.rs. This module contains helpers +// used during discretization and transform application to keep it up to date. diff --git a/crates/arcanum-geometry/src/tests/arc.rs b/crates/arcanum-geometry/src/tests/arc.rs new file mode 100644 index 0000000..7ff1b2f --- /dev/null +++ b/crates/arcanum-geometry/src/tests/arc.rs @@ -0,0 +1,232 @@ +// arc.rs — V-ARC validation cases (Steps 3 and 6) +// +// V-ARC-001 and V-ARC-002 test arc discretization endpoints (Step 3). +// V-ARC-002 self-loop junction assertion is TODO (Step 6). +// V-ARC-003 near-coincident warning assertion is TODO (Step 10). + +use std::f64::consts::PI; + +use arcanum_nec_import::{ + ArcWire, GeometricGround, GeometryTransforms, MeshInput, WireDescription, +}; + +use crate::build_mesh; +use crate::mesh::CurveParams; + +use super::approx_eq; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn ga( + tag: u32, + n: u32, + radius: f64, + angle1: f64, + angle2: f64, + wire_radius: f64, +) -> WireDescription { + WireDescription::Arc(ArcWire { + tag, + segment_count: n, + arc_radius: radius, + angle1, + angle2, + radius: wire_radius, + }) +} + +fn free_space(wires: Vec) -> MeshInput { + MeshInput { + wires, + ground: GeometricGround::default(), + gpflag: 0, + transforms: GeometryTransforms::default(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-ARC-001 — Semicircular arc, 4 segments +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_arc_001_semicircle_4_segments() { + // GA 1 4 0.5 0.0 180.0 0.001 + // Arc in XZ plane: r(θ) = (R cosθ, 0, R sinθ), θ from 0° to 180°. + // Each segment subtends 45°. + let (mesh, _warnings) = build_mesh(free_space(vec![ga(1, 4, 0.5, 0.0, 180.0, 0.001)]), None) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 4); + + let tol = 1e-4; + + // Expected endpoints at θ = 0°, 45°, 90°, 135°, 180°. + let r = 0.5_f64; + let angles_deg = [0.0_f64, 45.0, 90.0, 135.0, 180.0]; + let pts: Vec<(f64, f64)> = angles_deg + .iter() + .map(|°| { + let theta = deg.to_radians(); + (r * theta.cos(), r * theta.sin()) + }) + .collect(); + + // Segment 0 start: θ = 0° → (0.5, 0, 0) + approx_eq!(mesh.segments[0].start().x, pts[0].0, tol); + approx_eq!(mesh.segments[0].start().y, 0.0, tol); + approx_eq!(mesh.segments[0].start().z, pts[0].1, tol); + + // Segment 0 end / Segment 1 start: θ = 45° → (0.3536, 0, 0.3536) + approx_eq!(mesh.segments[0].end().x, pts[1].0, tol); + approx_eq!(mesh.segments[0].end().z, pts[1].1, tol); + approx_eq!(mesh.segments[1].start().x, pts[1].0, tol); + approx_eq!(mesh.segments[1].start().z, pts[1].1, tol); + + // Segment 1 end: θ = 90° → (0, 0, 0.5) + approx_eq!(mesh.segments[1].end().x, pts[2].0, tol); + approx_eq!(mesh.segments[1].end().z, pts[2].1, tol); + + // Segment 2 end: θ = 135° → (-0.3536, 0, 0.3536) + approx_eq!(mesh.segments[2].end().x, pts[3].0, tol); + approx_eq!(mesh.segments[2].end().z, pts[3].1, tol); + + // Segment 3 end: θ = 180° → (-0.5, 0, 0) + approx_eq!(mesh.segments[3].end().x, pts[4].0, tol); + approx_eq!(mesh.segments[3].end().z, pts[4].1, tol); + + // y = 0 for all endpoints (arc is in XZ plane). + for seg in &mesh.segments { + approx_eq!(seg.start().y, 0.0, tol); + approx_eq!(seg.end().y, 0.0, tol); + } + + // CurveParams carries correct radius and monotone angles within [0, π]. + for (k, seg) in mesh.segments.iter().enumerate() { + if let CurveParams::Arc(p) = &seg.curve { + approx_eq!(p.radius, 0.5); + assert!( + p.theta1 >= 0.0 && p.theta1 < PI + 1e-9, + "theta1 out of range: {}", + p.theta1 + ); + assert!(p.theta2 > p.theta1, "theta2 <= theta1 for segment {}", k); + assert!(p.theta2 <= PI + 1e-9, "theta2 out of range: {}", p.theta2); + } else { + panic!("expected Arc curve for segment {}", k); + } + } + + // Wire radius and tag. + for seg in &mesh.segments { + approx_eq!(seg.wire_radius, 0.001); + assert_eq!(seg.tag, 1); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-ARC-002 — Full circle (loop antenna), 8 segments +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_arc_002_full_circle_8_segments() { + // GA 1 8 0.25 0.0 360.0 0.001 + let (mesh, _warnings) = build_mesh(free_space(vec![ga(1, 8, 0.25, 0.0, 360.0, 0.001)]), None) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 8); + + let tol = 1e-4; + + // Adjacent segment endpoints must be coincident (geometric continuity). + for k in 0..7usize { + let end_k = mesh.segments[k].end(); + let start_k1 = mesh.segments[k + 1].start(); + let gap = (end_k - start_k1).norm(); + assert!( + gap < 1e-12, + "gap between seg {} end and seg {} start: {} m", + k, + k + 1, + gap + ); + } + + // Closure: seg 7 end ≈ seg 0 start (the loop closes). + let first_start = mesh.segments[0].start(); + let last_end = mesh.segments[7].end(); + approx_eq!(first_start.x, last_end.x, tol); + approx_eq!(first_start.y, last_end.y, tol); + approx_eq!(first_start.z, last_end.z, tol); + + // Seg 0 start at θ = 0°: (0.25, 0, 0). + approx_eq!(first_start.x, 0.25, tol); + approx_eq!(first_start.y, 0.0, tol); + approx_eq!(first_start.z, 0.0, tol); + + // All y = 0 (arc in XZ plane). + for seg in &mesh.segments { + approx_eq!(seg.start().y, 0.0, tol); + approx_eq!(seg.end().y, 0.0, tol); + } + + // Self-loop junction: seg 7 end connects to seg 0 start; is_self_loop = true. + assert_eq!(mesh.junctions.len(), 1, "expected 1 (self-loop) junction"); + let j = &mesh.junctions[0]; + assert!(j.is_self_loop, "junction should be flagged as self-loop"); + assert_eq!(j.endpoints.len(), 2); + + use crate::mesh::EndpointSide; + let has_seg0_start = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 0 && ep.side == EndpointSide::Start); + let has_seg7_end = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 7 && ep.side == EndpointSide::End); + assert!(has_seg0_start, "self-loop junction missing seg 0 Start"); + assert!(has_seg7_end, "self-loop junction missing seg 7 End"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-ARC-003 — Near-coincident endpoints (359° arc) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_arc_003_near_coincident_gap() { + // GA 1 8 0.25 0.0 359.0 0.001 + // Arc almost closes — gap between start and end is very small. + let (mesh, _warnings) = build_mesh(free_space(vec![ga(1, 8, 0.25, 0.0, 359.0, 0.001)]), None) + .expect("build_mesh must succeed (no hard error)"); + + assert_eq!(mesh.segments.len(), 8, "expected 8 segments"); + + let tol = 1e-4; + + // Seg 0 start: θ = 0° → (0.25, 0, 0). + approx_eq!(mesh.segments[0].start().x, 0.25, tol); + approx_eq!(mesh.segments[0].start().z, 0.0, tol); + + // Seg 7 end: θ = 359° → very close to (0.25, 0, 0) but not equal. + let theta_end = 359.0_f64.to_radians(); + let expected_end_x = 0.25 * theta_end.cos(); + let expected_end_z = 0.25 * theta_end.sin(); + approx_eq!(mesh.segments[7].end().x, expected_end_x, tol); + approx_eq!(mesh.segments[7].end().z, expected_end_z, tol); + + // The gap is nonzero — start and end are NOT coincident. + let start = mesh.segments[0].start(); + let end = mesh.segments[7].end(); + let gap = (start - end).norm(); + assert!(gap > 0.0, "expected nonzero gap for 359° arc"); + assert!(gap < 0.01, "gap unexpectedly large: {} m", gap); + + // No junction created at the gap — endpoints are near-coincident but not merged. + assert!( + mesh.junctions.is_empty(), + "expected no junctions for 359° arc" + ); + // TODO(Step 10): assert a NearCoincidentEndpoints warning was emitted. +} diff --git a/crates/arcanum-geometry/src/tests/ground.rs b/crates/arcanum-geometry/src/tests/ground.rs new file mode 100644 index 0000000..b5e2e8f --- /dev/null +++ b/crates/arcanum-geometry/src/tests/ground.rs @@ -0,0 +1,204 @@ +// ground.rs — V-GND validation cases (Step 7) + +use arcanum_nec_import::{ + GeometricGround, GeometryTransforms, GroundElectrical, GroundModel, + GroundType as NecGroundType, MeshInput, StraightWire, WireDescription, +}; + +use crate::build_mesh; +use crate::mesh::GroundType; + +use super::approx_eq; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn gw( + tag: u32, + n: u32, + x1: f64, + y1: f64, + z1: f64, + x2: f64, + y2: f64, + z2: f64, + radius: f64, +) -> WireDescription { + WireDescription::Straight(StraightWire { + tag, + segment_count: n, + x1, + y1, + z1, + x2, + y2, + z2, + radius, + }) +} + +fn input_with_ground(wires: Vec, ground_type: NecGroundType) -> MeshInput { + MeshInput { + wires, + ground: GeometricGround { ground_type }, + gpflag: 0, + transforms: GeometryTransforms::default(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-GND-001 — No ground card +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_gnd_001_no_ground() { + // GW 1 4 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 / GE 0 + let (mesh, _) = build_mesh( + input_with_ground( + vec![gw(1, 4, 0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.001)], + NecGroundType::FreeSpace, + ), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.ground.ground_type, GroundType::None); + assert!(!mesh.ground.images_generated); + assert_eq!(mesh.real_segment_count(), 4); + assert_eq!(mesh.image_segment_count(), 0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-GND-002 — PEC ground, image generation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_gnd_002_pec_ground_image_generation() { + // GW 1 4 0.0 0.0 0.0 0.0 0.0 0.5 0.001 / GN 1 / GE 1 + // A vertical wire from z=0 to z=0.5 above PEC ground. + let (mesh, _) = build_mesh( + input_with_ground( + vec![gw(1, 4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001)], + NecGroundType::PEC, + ), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.ground.ground_type, GroundType::PEC); + assert!(mesh.ground.images_generated); + + // 4 real + 4 image = 8 total. + // (Note: the bottom segment starts at z=0 — it will get an image since z_end != 0.) + let real_count = mesh.real_segment_count(); + let image_count = mesh.image_segment_count(); + assert_eq!( + real_count + image_count, + 8, + "expected 8 total segments (4 real + 4 image)" + ); + assert_eq!(real_count, 4); + assert_eq!(image_count, 4); + + let tol = 1e-12; + + // Each image segment has z-coordinates negated. + for k in 0..4usize { + let real = &mesh.segments[k]; + let image = &mesh.segments[4 + k]; + assert!(image.is_image); + approx_eq!(image.start().x, real.start().x, tol); + approx_eq!(image.start().y, real.start().y, tol); + approx_eq!(image.start().z, -real.start().z, tol); + approx_eq!(image.end().x, real.end().x, tol); + approx_eq!(image.end().y, real.end().y, tol); + approx_eq!(image.end().z, -real.end().z, tol); + approx_eq!(image.wire_radius, real.wire_radius, tol); + assert_eq!(image.tag, real.tag); + } + + // One junction at z = 0: real segment 0 Start and image segment 4 Start share + // the ground-plane contact point. Adjacent image segments are the same wire and + // are skipped by the intra-wire adjacency rule. + assert_eq!(mesh.junctions.len(), 1); + assert!(!mesh.junctions[0].is_self_loop); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-GND-003 — Lossy ground parameters stored, no images +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_gnd_003_lossy_ground_params_stored() { + // GW 1 4 0.0 0.0 0.0 0.0 0.0 0.5 0.001 / GN 2 0 0 0 13.0 0.005 / GE 1 + let ground_elec = GroundElectrical { + permittivity: 13.0, + conductivity: 0.005, + model: GroundModel::ReflectionCoeff, + }; + + let (mesh, _) = build_mesh( + input_with_ground( + vec![gw(1, 4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001)], + NecGroundType::Lossy, + ), + Some(ground_elec), + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.ground.ground_type, GroundType::Lossy); + assert!( + !mesh.ground.images_generated, + "lossy ground must not generate images" + ); + assert_eq!(mesh.image_segment_count(), 0); + assert_eq!(mesh.real_segment_count(), 4); + + // Electrical parameters stored for Phase 2. + approx_eq!( + mesh.ground.conductivity.expect("conductivity missing"), + 0.005 + ); + approx_eq!( + mesh.ground.permittivity.expect("permittivity missing"), + 13.0 + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-GND-004 — Wire in ground plane (no self-image, warning emitted) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_gnd_004_wire_in_ground_plane() { + // GW 1 4 -0.25 0.0 0.0 0.25 0.0 0.0 0.001 / GN 1 / GE 1 + // All z-coordinates = 0.0 → wire lies in ground plane. + let (mesh, warnings) = build_mesh( + input_with_ground( + vec![gw(1, 4, -0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.001)], + NecGroundType::PEC, + ), + None, + ) + .expect("build_mesh failed"); + + // No image segments — wire is its own image. + assert_eq!( + mesh.image_segment_count(), + 0, + "no images for wire in ground plane" + ); + assert_eq!(mesh.real_segment_count(), 4); + // No junctions — single wire, no images, no cross-wire connections. + assert!(mesh.junctions.is_empty()); + + // Warning emitted. + let warn_vec = warnings.into_vec(); + assert!(!warn_vec.is_empty(), "expected WireInGroundPlane warning"); + use crate::errors::GeometryWarningKind; + let has_warn = warn_vec + .iter() + .any(|w| w.kind == GeometryWarningKind::WireInGroundPlane); + assert!(has_warn, "expected WireInGroundPlane warning kind"); +} diff --git a/crates/arcanum-geometry/src/tests/helix.rs b/crates/arcanum-geometry/src/tests/helix.rs new file mode 100644 index 0000000..0666702 --- /dev/null +++ b/crates/arcanum-geometry/src/tests/helix.rs @@ -0,0 +1,249 @@ +// helix.rs — V-HEL validation cases (Steps 4 and 7) +// +// V-HEL-001 and V-HEL-002 test helix discretization (Step 4). +// V-HEL-003 PEC image assertions are TODO (Step 7). +// +// NOTE: The GH card strings in validation.md V-HEL-001/002 have an extra +// field and an inconsistent total_length value. Tests here construct +// HelixWire structs directly with the physically correct values that match +// the stated expected outputs (one and five turns respectively). + +use arcanum_nec_import::{ + GeometricGround, GeometricGround as NecGeometricGround, GeometryTransforms, + GroundType as NecGroundType, HelixWire, MeshInput, WireDescription, +}; + +use crate::build_mesh; +use crate::mesh::CurveParams; + +use super::approx_eq; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn helix_wire( + tag: u32, + n: u32, + pitch: f64, + total_length: f64, + radius_start: f64, + radius_end: f64, + wire_radius: f64, +) -> WireDescription { + WireDescription::Helix(HelixWire { + tag, + segment_count: n, + pitch, + total_length, + radius_start, + radius_end, + radius: wire_radius, + n_turns: total_length / pitch, + }) +} + +fn free_space(wires: Vec) -> MeshInput { + MeshInput { + wires, + ground: GeometricGround::default(), + gpflag: 0, + transforms: GeometryTransforms::default(), + } +} + +fn pec_ground(wires: Vec) -> MeshInput { + MeshInput { + wires, + ground: NecGeometricGround { + ground_type: NecGroundType::PEC, + }, + gpflag: 1, + transforms: GeometryTransforms::default(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-HEL-001 — Single-turn helix, 8 segments +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_hel_001_single_turn_8_segments() { + // GH 1 8 0.05 0.05 0.001 + // n_turns = total_length / pitch = 1.0 + let pitch = 0.0628_f64; + let (mesh, _warnings) = build_mesh( + free_space(vec![helix_wire(1, 8, pitch, pitch, 0.05, 0.05, 0.001)]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 8); + + let tol = 1e-4; + + // Segment 0 start: t = 0 → (A1, 0, 0) = (0.05, 0.0, 0.0) + approx_eq!(mesh.segments[0].start().x, 0.05, tol); + approx_eq!(mesh.segments[0].start().y, 0.0, tol); + approx_eq!(mesh.segments[0].start().z, 0.0, tol); + + // After one full turn (t = 1): same x,y as start, z = total_length = 0.0628. + // angle = 2π * n_turns * t = 2π → cos(2π) = 1, sin(2π) = 0. + approx_eq!(mesh.segments[7].end().x, 0.05, tol); + approx_eq!(mesh.segments[7].end().y, 0.0, tol); + approx_eq!(mesh.segments[7].end().z, pitch, tol); + + // Adjacent segment endpoints are coincident (no gaps). + for k in 0..7usize { + let end_k = mesh.segments[k].end(); + let start_k1 = mesh.segments[k + 1].start(); + let gap = (end_k - start_k1).norm(); + assert!( + gap < 1e-12, + "gap between seg {} end and seg {} start: {} m", + k, + k + 1, + gap + ); + } + + // Each segment subtends 45° = π/4 radians of rotation. + // Confirm intermediate endpoints at 45° increments. + use std::f64::consts::PI; + for k in 0..8usize { + if let CurveParams::Helix(p) = &mesh.segments[k].curve { + assert_eq!(p.n_segments, 8); + assert_eq!(p.segment_index as usize, k); + approx_eq!(p.total_length, pitch); + approx_eq!(p.n_turns, 1.0); + approx_eq!(p.radius_start, 0.05); + approx_eq!(p.radius_end, 0.05); + } else { + panic!("expected Helix curve for segment {}", k); + } + + // z-coordinate at segment start should be k/8 * total_length. + let expected_z_start = k as f64 / 8.0 * pitch; + approx_eq!(mesh.segments[k].start().z, expected_z_start, tol); + + // x,y at segment start: angle = 2π * (k/8). + let theta = 2.0 * PI * (k as f64 / 8.0); + approx_eq!(mesh.segments[k].start().x, 0.05 * theta.cos(), tol); + approx_eq!(mesh.segments[k].start().y, 0.05 * theta.sin(), tol); + } + + // Wire radius and tag. + for seg in &mesh.segments { + approx_eq!(seg.wire_radius, 0.001); + assert_eq!(seg.tag, 1); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-HEL-002 — Multi-turn helix, endpoint continuity +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_hel_002_five_turn_endpoint_continuity() { + // 5-turn helix: 40 segments at 8 per turn. + // total_length = 5 * 0.0628 = 0.314 m + let pitch = 0.0628_f64; + let total_length = 5.0 * pitch; + let (mesh, _warnings) = build_mesh( + free_space(vec![helix_wire( + 1, + 40, + pitch, + total_length, + 0.05, + 0.05, + 0.001, + )]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 40); + + // Primary: geometric continuity — gap between consecutive segment endpoints + // must be zero to double precision (< 1e-12 m). + for k in 0..39usize { + let end_k = mesh.segments[k].end(); + let start_k1 = mesh.segments[k + 1].start(); + let gap = (end_k - start_k1).norm(); + assert!( + gap < 1e-12, + "continuity failure: gap between seg {} end and seg {} start = {} m", + k, + k + 1, + gap + ); + } + + // Final z-coordinate: total_length = 0.314 m. + let tol = 1e-9; + approx_eq!(mesh.segments[39].end().z, total_length, tol); + + // Segment 0 start: (0.05, 0.0, 0.0). + approx_eq!(mesh.segments[0].start().x, 0.05, tol); + approx_eq!(mesh.segments[0].start().y, 0.0, tol); + approx_eq!(mesh.segments[0].start().z, 0.0, tol); + + // After 5 full turns (t = 1): angle = 2π * 5 * 1 = 10π → cos = 1, sin = 0. + // Final endpoint should be at (0.05, 0.0, 0.314). + approx_eq!(mesh.segments[39].end().x, 0.05, 1e-9); + approx_eq!(mesh.segments[39].end().y, 0.0, 1e-9); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-HEL-003 — Helix over PEC ground plane +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_hel_003_helix_over_pec_ground() { + // GH 1 16 0.0628 0.1256 0.05 0.05 0.001 (2-turn helix) + // GN 1 / GE 1 + let pitch = 0.0628_f64; + let total_length = 2.0 * pitch; + let (mesh, _warnings) = build_mesh( + pec_ground(vec![helix_wire( + 1, + 16, + pitch, + total_length, + 0.05, + 0.05, + 0.001, + )]), + None, + ) + .expect("build_mesh failed"); + + // 16 real + 16 image = 32 total. + let real_count = mesh.real_segment_count(); + let image_count = mesh.image_segment_count(); + assert_eq!(real_count, 16, "expected 16 real segments"); + assert_eq!(image_count, 16, "expected 16 image segments"); + assert_eq!(mesh.segments.len(), 32); + + // Ground descriptor. + assert_eq!(mesh.ground.ground_type, crate::mesh::GroundType::PEC); + assert!(mesh.ground.images_generated); + + let tol = 1e-12; + // Image segments have z-coordinates negated. + for k in 0..16usize { + let real = &mesh.segments[k]; + let image = &mesh.segments[16 + k]; + assert!(image.is_image); + approx_eq!(image.start().z, -real.start().z, tol); + approx_eq!(image.end().z, -real.end().z, tol); + approx_eq!(image.start().x, real.start().x, tol); + approx_eq!(image.start().y, real.start().y, tol); + } + + // One junction at z = 0: real segment 0 Start and image segment 16 Start share + // the helix feed point at the ground plane. + assert_eq!(mesh.junctions.len(), 1); + assert!(!mesh.junctions[0].is_self_loop); +} diff --git a/crates/arcanum-geometry/src/tests/linear.rs b/crates/arcanum-geometry/src/tests/linear.rs new file mode 100644 index 0000000..3585c6c --- /dev/null +++ b/crates/arcanum-geometry/src/tests/linear.rs @@ -0,0 +1,384 @@ +// linear.rs — V-LIN validation cases (Steps 2 and 6) +// +// V-LIN-001, V-LIN-002, V-LIN-005, V-LIN-006 test discretization (Step 2). +// V-LIN-003, V-LIN-004 segment assertions are in Step 2; junction assertions +// are marked TODO and will be filled in when Step 6 (junctions.rs) is complete. + +use arcanum_nec_import::{ + GeometricGround, GeometryTransforms, MeshInput, StraightWire, WireDescription, +}; + +use crate::errors::GeometryErrorKind; +use crate::mesh::CurveParams; +use crate::{build_mesh, mesh::Material}; + +use super::approx_eq; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn gw( + tag: u32, + n: u32, + x1: f64, + y1: f64, + z1: f64, + x2: f64, + y2: f64, + z2: f64, + radius: f64, +) -> WireDescription { + WireDescription::Straight(StraightWire { + tag, + segment_count: n, + x1, + y1, + z1, + x2, + y2, + z2, + radius, + }) +} + +fn free_space(wires: Vec) -> MeshInput { + MeshInput { + wires, + ground: GeometricGround::default(), + gpflag: 0, + transforms: GeometryTransforms::default(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-001 — Single straight wire, two segments, z-axis +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_001_two_segment_dipole() { + // GW 1 2 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 + let (mesh, _warnings) = build_mesh( + free_space(vec![gw(1, 2, 0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.001)]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 2, "expected 2 segments"); + + let s0 = &mesh.segments[0]; + let s1 = &mesh.segments[1]; + + // Segment 0: (0, 0, -0.25) → (0, 0, 0.0) + approx_eq!(s0.start().x, 0.0); + approx_eq!(s0.start().y, 0.0); + approx_eq!(s0.start().z, -0.25); + approx_eq!(s0.end().x, 0.0); + approx_eq!(s0.end().y, 0.0); + approx_eq!(s0.end().z, 0.0); + + // Segment 1: (0, 0, 0.0) → (0, 0, 0.25) + approx_eq!(s1.start().x, 0.0); + approx_eq!(s1.start().y, 0.0); + approx_eq!(s1.start().z, 0.0); + approx_eq!(s1.end().x, 0.0); + approx_eq!(s1.end().y, 0.0); + approx_eq!(s1.end().z, 0.25); + + // Adjacent endpoints are coincident (shared midpoint). + approx_eq!(s0.end().z, s1.start().z); + + // Radius, material, tag. + approx_eq!(s0.wire_radius, 0.001); + approx_eq!(s1.wire_radius, 0.001); + assert_eq!(s0.material, Material::PEC); + assert_eq!(s1.material, Material::PEC); + assert_eq!(s0.tag, 1); + assert_eq!(s1.tag, 1); + + // Lengths via CurveParams. + if let CurveParams::Linear(p) = &s0.curve { + approx_eq!(p.length(), 0.25); + } else { + panic!("expected Linear curve"); + } + if let CurveParams::Linear(p) = &s1.curve { + approx_eq!(p.length(), 0.25); + } else { + panic!("expected Linear curve"); + } + + // Ground descriptor: free space. + assert_eq!(mesh.ground.ground_type, crate::mesh::GroundType::None); + + // Intra-wire adjacent connections are NOT junctions (the midpoint at z=0 is + // an implicit within-wire segment boundary, not a cross-wire junction). + // Both outer endpoints (z=-0.25 and z=0.25) are free. + use crate::mesh::EndpointSide; + assert!( + mesh.junctions.is_empty(), + "intra-wire midpoint should not be a junction" + ); + assert!( + mesh.junction_at(0, &EndpointSide::Start).is_none(), + "seg 0 start should be free" + ); + assert!( + mesh.junction_at(1, &EndpointSide::End).is_none(), + "seg 1 end should be free" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-002 — Single segment, x-axis +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_002_single_segment_x_axis() { + // GW 1 1 0.0 0.0 0.0 1.0 0.0 0.0 0.005 + let (mesh, _warnings) = build_mesh( + free_space(vec![gw(1, 1, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.005)]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 1, "expected 1 segment"); + + let s0 = &mesh.segments[0]; + + approx_eq!(s0.start().x, 0.0); + approx_eq!(s0.start().y, 0.0); + approx_eq!(s0.start().z, 0.0); + approx_eq!(s0.end().x, 1.0); + approx_eq!(s0.end().y, 0.0); + approx_eq!(s0.end().z, 0.0); + + approx_eq!(s0.wire_radius, 0.005); + assert_eq!(s0.tag, 1); + assert_eq!(s0.segment_index, 0); + + if let CurveParams::Linear(p) = &s0.curve { + approx_eq!(p.length(), 1.0); + } else { + panic!("expected Linear curve"); + } + + // Tag map: tag 1 → segment indices [0, 0]. + let range = mesh.tag_map.get(1).expect("tag 1 not in tag map"); + assert_eq!(range, (0, 0)); + assert_eq!(mesh.tag_map.segment_count(1), Some(1)); + + // No junctions (single segment, two free endpoints). + assert!( + mesh.junctions.is_empty(), + "expected no junctions for single segment" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-003 — Two wires joined at a junction +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_003_two_wires_shared_endpoint() { + // GW 1 3 0.0 0.0 0.0 1.0 0.0 0.0 0.001 + // GW 2 3 1.0 0.0 0.0 2.0 0.0 0.0 0.001 + let (mesh, _warnings) = build_mesh( + free_space(vec![ + gw(1, 3, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.001), + gw(2, 3, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.001), + ]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 6, "expected 6 segments"); + + // Wire 1 segments: indices 0, 1, 2 — evenly spaced along x from 0 to 1. + let step = 1.0 / 3.0; + for k in 0..3usize { + let s = &mesh.segments[k]; + approx_eq!(s.start().x, k as f64 * step); + approx_eq!(s.end().x, (k + 1) as f64 * step); + approx_eq!(s.start().y, 0.0); + approx_eq!(s.start().z, 0.0); + assert_eq!(s.tag, 1); + assert_eq!(s.wire_index, 0); + } + + // Wire 2 segments: indices 3, 4, 5 — evenly spaced along x from 1 to 2. + for k in 0..3usize { + let s = &mesh.segments[3 + k]; + approx_eq!(s.start().x, 1.0 + k as f64 * step); + approx_eq!(s.end().x, 1.0 + (k + 1) as f64 * step); + assert_eq!(s.tag, 2); + assert_eq!(s.wire_index, 1); + } + + // Shared endpoint at (1, 0, 0): wire 1 seg 2 end == wire 2 seg 3 start. + approx_eq!(mesh.segments[2].end().x, 1.0); + approx_eq!(mesh.segments[3].start().x, 1.0); + + // Tag map. + assert_eq!(mesh.tag_map.get(1), Some((0, 2))); + assert_eq!(mesh.tag_map.get(2), Some((3, 5))); + + // One junction at (1,0,0) connecting seg 2 end to seg 3 start. + assert_eq!(mesh.junctions.len(), 1, "expected 1 junction"); + let j = &mesh.junctions[0]; + assert_eq!(j.endpoints.len(), 2, "junction valence should be 2"); + assert!(!j.is_self_loop); + + use crate::mesh::EndpointSide; + let has_seg2_end = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 2 && ep.side == EndpointSide::End); + let has_seg3_start = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 3 && ep.side == EndpointSide::Start); + assert!(has_seg2_end, "junction missing seg 2 End"); + assert!(has_seg3_start, "junction missing seg 3 Start"); + + // Free endpoints at (0,0,0) and (2,0,0). + assert!( + mesh.junction_at(0, &EndpointSide::Start).is_none(), + "seg 0 start should be free" + ); + assert!( + mesh.junction_at(5, &EndpointSide::End).is_none(), + "seg 5 end should be free" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-004 — T-junction, three wires meeting at origin +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_004_t_junction_three_wires() { + // GW 1 2 -0.5 0.0 0.0 0.5 0.0 0.0 0.001 + // GW 2 2 0.0 0.0 0.0 0.0 0.5 0.0 0.001 + // GW 3 2 0.0 0.0 0.0 0.0 0.0 0.5 0.001 + let (mesh, _warnings) = build_mesh( + free_space(vec![ + gw(1, 2, -0.5, 0.0, 0.0, 0.5, 0.0, 0.0, 0.001), + gw(2, 2, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.001), + gw(3, 2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001), + ]), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 6, "expected 6 segments"); + + // Wire 1: midpoint at origin (seg 0 end, seg 1 start). + approx_eq!(mesh.segments[0].end().x, 0.0); + approx_eq!(mesh.segments[0].end().y, 0.0); + approx_eq!(mesh.segments[0].end().z, 0.0); + approx_eq!(mesh.segments[1].start().x, 0.0); + + // Wire 2: start at origin. + approx_eq!(mesh.segments[2].start().x, 0.0); + approx_eq!(mesh.segments[2].start().y, 0.0); + approx_eq!(mesh.segments[2].start().z, 0.0); + + // Wire 3: start at origin. + approx_eq!(mesh.segments[4].start().x, 0.0); + approx_eq!(mesh.segments[4].start().y, 0.0); + approx_eq!(mesh.segments[4].start().z, 0.0); + + // Wire 1 outer endpoints. + approx_eq!(mesh.segments[0].start().x, -0.5); + approx_eq!(mesh.segments[1].end().x, 0.5); + + // Tag map. + assert_eq!(mesh.tag_map.get(1), Some((0, 1))); + assert_eq!(mesh.tag_map.get(2), Some((2, 3))); + assert_eq!(mesh.tag_map.get(3), Some((4, 5))); + + // One junction at (0,0,0) where 3 wires meet. + // Endpoints in the junction: + // seg 0 End and seg 1 Start (wire 1 midpoint, 2 endpoints) + // seg 2 Start (wire 2 start) + // seg 4 Start (wire 3 start) + // Total: 4 segment endpoints, representing 3 wires (valence 3). + assert_eq!(mesh.junctions.len(), 1, "expected 1 junction"); + let j = &mesh.junctions[0]; + assert_eq!( + j.endpoints.len(), + 4, + "T-junction should have 4 segment endpoints (3 wires)" + ); + assert!(!j.is_self_loop); + + use crate::mesh::EndpointSide; + let has_seg0_end = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 0 && ep.side == EndpointSide::End); + let has_seg1_start = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 1 && ep.side == EndpointSide::Start); + let has_seg2_start = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 2 && ep.side == EndpointSide::Start); + let has_seg4_start = j + .endpoints + .iter() + .any(|ep| ep.segment_index == 4 && ep.side == EndpointSide::Start); + assert!(has_seg0_end, "junction missing seg 0 End (wire 1 midpoint)"); + assert!( + has_seg1_start, + "junction missing seg 1 Start (wire 1 midpoint)" + ); + assert!(has_seg2_start, "junction missing seg 2 Start (wire 2)"); + assert!(has_seg4_start, "junction missing seg 4 Start (wire 3)"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-005 — Zero-length wire (hard error) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_005_zero_length_wire_error() { + // GW 1 4 0.5 0.5 0.5 0.5 0.5 0.5 0.001 — start == end + let result = build_mesh( + free_space(vec![gw(1, 4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.001)]), + None, + ); + + let err = result.expect_err("expected hard error for zero-length wire"); + assert_eq!( + err.kind, + GeometryErrorKind::ZeroLengthWire, + "wrong error kind: {:?}", + err.kind + ); + assert_eq!(err.wire_index, 0, "wrong wire index in error"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-LIN-006 — Zero segment count (hard error) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_lin_006_zero_segment_count_error() { + // GW 1 0 0.0 0.0 0.0 1.0 0.0 0.0 0.001 — SEGS = 0 + let result = build_mesh( + free_space(vec![gw(1, 0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.001)]), + None, + ); + + let err = result.expect_err("expected hard error for zero segment count"); + assert_eq!( + err.kind, + GeometryErrorKind::ZeroSegmentCount, + "wrong error kind: {:?}", + err.kind + ); + assert_eq!(err.wire_index, 0, "wrong wire index in error"); +} diff --git a/crates/arcanum-geometry/src/tests/mod.rs b/crates/arcanum-geometry/src/tests/mod.rs new file mode 100644 index 0000000..070adb0 --- /dev/null +++ b/crates/arcanum-geometry/src/tests/mod.rs @@ -0,0 +1,33 @@ +// Phase 1 test suite — one file per validation category + +mod arc; +mod ground; +mod helix; +mod linear; +mod tagmap; +mod transforms; +mod warnings; + +/// Approximate equality helper for f64, matching the nec-import convention. +macro_rules! approx_eq { + ($a:expr, $b:expr) => { + assert!( + ($a - $b).abs() < 1e-9, + "approx_eq failed: {} ≠ {} (diff = {})", + $a, + $b, + ($a - $b).abs() + ) + }; + ($a:expr, $b:expr, $tol:expr) => { + assert!( + ($a - $b).abs() < $tol, + "approx_eq failed: {} ≠ {} (diff = {}, tol = {})", + $a, + $b, + ($a - $b).abs(), + $tol + ) + }; +} +pub(crate) use approx_eq; diff --git a/crates/arcanum-geometry/src/tests/tagmap.rs b/crates/arcanum-geometry/src/tests/tagmap.rs new file mode 100644 index 0000000..abc80d2 --- /dev/null +++ b/crates/arcanum-geometry/src/tests/tagmap.rs @@ -0,0 +1,79 @@ +// tagmap.rs — V-TAG validation cases (Step 8) + +use arcanum_nec_import::{ + GeometricGround, GeometryTransforms, MeshInput, StraightWire, WireDescription, +}; + +use crate::build_mesh; + +// ───────────────────────────────────────────────────────────────────────────── +// V-TAG-001 — Multiple wires, tag map correctness +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_tag_001_multiple_wires_tag_map() { + // GW 1 3 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 → segments 0, 1, 2 + // GW 2 5 0.0 0.0 0.5 1.0 0.0 0.5 0.001 → segments 3, 4, 5, 6, 7 + let wires = vec![ + WireDescription::Straight(StraightWire { + tag: 1, + segment_count: 3, + x1: 0.0, + y1: 0.0, + z1: -0.25, + x2: 0.0, + y2: 0.0, + z2: 0.25, + radius: 0.001, + }), + WireDescription::Straight(StraightWire { + tag: 2, + segment_count: 5, + x1: 0.0, + y1: 0.0, + z1: 0.5, + x2: 1.0, + y2: 0.0, + z2: 0.5, + radius: 0.001, + }), + ]; + + let (mesh, _) = build_mesh( + MeshInput { + wires, + ground: GeometricGround::default(), + gpflag: 0, + transforms: GeometryTransforms::default(), + }, + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 8, "expected 8 total segments"); + + // Tag 1 → segment indices [0, 2]. + let (first1, last1) = mesh.tag_map.get(1).expect("tag 1 not in tag map"); + assert_eq!(first1, 0); + assert_eq!(last1, 2); + assert_eq!(mesh.tag_map.segment_count(1), Some(3)); + + // Tag 2 → segment indices [3, 7]. + let (first2, last2) = mesh.tag_map.get(2).expect("tag 2 not in tag map"); + assert_eq!(first2, 3); + assert_eq!(last2, 7); + assert_eq!(mesh.tag_map.segment_count(2), Some(5)); + + // Verify actual segment tags match. + for k in 0..3usize { + assert_eq!(mesh.segments[k].tag, 1, "segment {} should have tag 1", k); + } + for k in 3..8usize { + assert_eq!(mesh.segments[k].tag, 2, "segment {} should have tag 2", k); + } + + // Segment indices are correct. + for (k, seg) in mesh.segments.iter().enumerate() { + assert_eq!(seg.segment_index, k, "segment_index mismatch at k={}", k); + } +} diff --git a/crates/arcanum-geometry/src/tests/transforms.rs b/crates/arcanum-geometry/src/tests/transforms.rs new file mode 100644 index 0000000..06bd300 --- /dev/null +++ b/crates/arcanum-geometry/src/tests/transforms.rs @@ -0,0 +1,258 @@ +// transforms.rs — V-TRF validation cases (Step 5) + +use arcanum_nec_import::{ + GeometricGround, GeometryTransforms, GmOperation, MeshInput, StraightWire, WireDescription, +}; + +use crate::build_mesh; + +use super::approx_eq; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn gw( + tag: u32, + n: u32, + x1: f64, + y1: f64, + z1: f64, + x2: f64, + y2: f64, + z2: f64, + radius: f64, +) -> WireDescription { + WireDescription::Straight(StraightWire { + tag, + segment_count: n, + x1, + y1, + z1, + x2, + y2, + z2, + radius, + }) +} + +fn input(wires: Vec, transforms: GeometryTransforms) -> MeshInput { + MeshInput { + wires, + ground: GeometricGround::default(), + gpflag: 0, + transforms, + } +} + +fn no_rotation_no_translation() -> GmOperation { + GmOperation { + tag: 0, + n_copies: 0, + rot_x: 0.0, + rot_y: 0.0, + rot_z: 0.0, + trans_x: 0.0, + trans_y: 0.0, + trans_z: 0.0, + tag_increment: 0, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-TRF-001 — GS global scale +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_trf_001_gs_global_scale() { + // GW 1 4 0.0 0.0 -0.25 0.0 0.0 0.25 0.01 + // GS 0 0 0.5 — scale all coordinates by 0.5; wire radius NOT scaled + let transforms = GeometryTransforms { + gs_scale: Some(0.5), + gm_ops: vec![], + }; + let (mesh, _) = build_mesh( + input( + vec![gw(1, 4, 0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.01)], + transforms, + ), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 4); + + // z range should now be -0.125 to 0.125 (scaled by 0.5). + let tol = 1e-12; + approx_eq!(mesh.segments[0].start().z, -0.125, tol); + approx_eq!(mesh.segments[3].end().z, 0.125, tol); + + // x and y remain 0. + for seg in &mesh.segments { + approx_eq!(seg.start().x, 0.0, tol); + approx_eq!(seg.start().y, 0.0, tol); + approx_eq!(seg.end().x, 0.0, tol); + approx_eq!(seg.end().y, 0.0, tol); + } + + // Wire radius must NOT be scaled — still 0.01. + for seg in &mesh.segments { + approx_eq!(seg.wire_radius, 0.01, tol); + } + + // Total wire length: 0.25 m (was 0.5 m, scaled by 0.5). + let total_z = mesh.segments[3].end().z - mesh.segments[0].start().z; + approx_eq!(total_z, 0.25, tol); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-TRF-002 — GM translation +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_trf_002_gm_translation() { + // GW 1 2 0.0 0.0 -0.25 0.0 0.0 0.25 0.001 + // GM 0 0 0.0 0.0 0.0 1.0 0.0 0.0 — translate all by (+1, 0, 0) + let mut gm = no_rotation_no_translation(); + gm.trans_x = 1.0; + + let transforms = GeometryTransforms { + gs_scale: None, + gm_ops: vec![gm], + }; + + let (mesh, _) = build_mesh( + input( + vec![gw(1, 2, 0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.001)], + transforms, + ), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 2); + + let tol = 1e-12; + + // All x-coordinates should be +1.0. + approx_eq!(mesh.segments[0].start().x, 1.0, tol); + approx_eq!(mesh.segments[0].end().x, 1.0, tol); + approx_eq!(mesh.segments[1].start().x, 1.0, tol); + approx_eq!(mesh.segments[1].end().x, 1.0, tol); + + // y and z unchanged. + approx_eq!(mesh.segments[0].start().y, 0.0, tol); + approx_eq!(mesh.segments[0].start().z, -0.25, tol); + approx_eq!(mesh.segments[1].end().z, 0.25, tol); + + // Explicit segment coordinates from validation.md: + // seg 0 start: (1.0, 0.0, -0.25) + approx_eq!(mesh.segments[0].start().x, 1.0, tol); + approx_eq!(mesh.segments[0].start().z, -0.25, tol); + // seg 1 end: (1.0, 0.0, 0.25) + approx_eq!(mesh.segments[1].end().x, 1.0, tol); + approx_eq!(mesh.segments[1].end().z, 0.25, tol); + + // Wire radius unchanged. + approx_eq!(mesh.segments[0].wire_radius, 0.001, tol); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-TRF-003 — GM rotation about z-axis (90°) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_trf_003_gm_rotation_z_90deg() { + // GW 1 1 1.0 0.0 0.0 1.0 0.0 1.0 0.001 + // GM 0 0 0.0 0.0 90.0 0.0 0.0 0.0 — rotate all 90° around z-axis + // After 90° Rz: (x, y, z) → (-y, x, z) + // (1, 0, 0) → (0, 1, 0) + // (1, 0, 1) → (0, 1, 1) + let mut gm = no_rotation_no_translation(); + gm.rot_z = 90.0; + + let transforms = GeometryTransforms { + gs_scale: None, + gm_ops: vec![gm], + }; + + let (mesh, _) = build_mesh( + input( + vec![gw(1, 1, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.001)], + transforms, + ), + None, + ) + .expect("build_mesh failed"); + + assert_eq!(mesh.segments.len(), 1); + + let tol = 1e-9; + + // start: (1,0,0) → (0, 1, 0) + approx_eq!(mesh.segments[0].start().x, 0.0, tol); + approx_eq!(mesh.segments[0].start().y, 1.0, tol); + approx_eq!(mesh.segments[0].start().z, 0.0, tol); + + // end: (1,0,1) → (0, 1, 1) + approx_eq!(mesh.segments[0].end().x, 0.0, tol); + approx_eq!(mesh.segments[0].end().y, 1.0, tol); + approx_eq!(mesh.segments[0].end().z, 1.0, tol); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-TRF-004 — GM n_copies > 0: generate translated copies +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_trf_004_gm_copies_translation() { + // GW 1 1 0.0 0.0 0.0 1.0 0.0 0.0 0.001 + // GM 0 2 0.0 0.0 0.0 0.0 1.0 0.0 ITS=1 + // n_copies=2, trans_y=1.0 → 2 additional copies at y=1 and y=2 + let gm = GmOperation { + tag: 0, + n_copies: 2, + rot_x: 0.0, + rot_y: 0.0, + rot_z: 0.0, + trans_x: 0.0, + trans_y: 1.0, + trans_z: 0.0, + tag_increment: 1, + }; + + let transforms = GeometryTransforms { + gs_scale: None, + gm_ops: vec![gm], + }; + + let (mesh, _) = build_mesh( + input( + vec![gw(1, 1, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.001)], + transforms, + ), + None, + ) + .expect("build_mesh failed"); + + // Original + 2 copies = 3 segments total. + assert_eq!(mesh.segments.len(), 3); + + let tol = 1e-12; + + // Original (tag 1): y = 0. + approx_eq!(mesh.segments[0].start().y, 0.0, tol); + assert_eq!(mesh.segments[0].tag, 1); + + // Copy 1 (tag 2): y = 1. + approx_eq!(mesh.segments[1].start().y, 1.0, tol); + assert_eq!(mesh.segments[1].tag, 2); + + // Copy 2 (tag 3): y = 2. + approx_eq!(mesh.segments[2].start().y, 2.0, tol); + assert_eq!(mesh.segments[2].tag, 3); + + // Tag map: tag 2 and tag 3 registered. + assert_eq!(mesh.tag_map.get(2), Some((1, 1))); + assert_eq!(mesh.tag_map.get(3), Some((2, 2))); +} diff --git a/crates/arcanum-geometry/src/tests/warnings.rs b/crates/arcanum-geometry/src/tests/warnings.rs new file mode 100644 index 0000000..c440d4c --- /dev/null +++ b/crates/arcanum-geometry/src/tests/warnings.rs @@ -0,0 +1,123 @@ +// warnings.rs — V-WARN validation cases (Step 10) + +use arcanum_nec_import::{ + GeometricGround, GeometryTransforms, MeshInput, StraightWire, WireDescription, +}; + +use crate::build_mesh; +use crate::errors::GeometryWarningKind; + +// ───────────────────────────────────────────────────────────────────────────── +// V-WARN-001 — WireInGroundPlane warning +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_warn_001_wire_in_ground_plane_warning() { + use arcanum_nec_import::GroundType as NecGroundType; + + // Horizontal wire at z=0 above PEC ground. + let (_, warnings) = build_mesh( + MeshInput { + wires: vec![WireDescription::Straight(StraightWire { + tag: 1, + segment_count: 2, + x1: -0.5, + y1: 0.0, + z1: 0.0, + x2: 0.5, + y2: 0.0, + z2: 0.0, + radius: 0.001, + })], + ground: GeometricGround { + ground_type: NecGroundType::PEC, + }, + gpflag: 1, + transforms: GeometryTransforms::default(), + }, + None, + ) + .expect("build_mesh must succeed"); + + let warn_vec = warnings.into_vec(); + assert!(!warn_vec.is_empty(), "expected at least one warning"); + let has_ground_warn = warn_vec + .iter() + .any(|w| w.kind == GeometryWarningKind::WireInGroundPlane); + assert!( + has_ground_warn, + "expected WireInGroundPlane warning, got: {:?}", + warn_vec + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// V-WARN-002 — NearCoincidentEndpoints warning +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn v_warn_002_near_coincident_endpoints_warning() { + // Two wires whose endpoints almost share a point. + // GW 1 1 0.0 0.0 0.0 1.0 0.0 0.0 wire_radius=0.001 + // GW 2 1 1.0 0.0 5e-5 2.0 0.0 5e-5 wire_radius=0.001 + // + // Gap between wire 1 end (1.0, 0, 0) and wire 2 start (1.0, 0, 5e-5) = 5e-5 m. + // ε = min(0.001, 0.001) × 0.01 = 1e-5 m. + // 10ε = 1e-4 m. + // gap (5e-5) is in [ε, 10ε] → near-coincident warning, no junction. + let gap = 5e-5_f64; + let (mesh, warnings) = build_mesh( + MeshInput { + wires: vec![ + WireDescription::Straight(StraightWire { + tag: 1, + segment_count: 1, + x1: 0.0, + y1: 0.0, + z1: 0.0, + x2: 1.0, + y2: 0.0, + z2: 0.0, + radius: 0.001, + }), + WireDescription::Straight(StraightWire { + tag: 2, + segment_count: 1, + x1: 1.0, + y1: 0.0, + z1: gap, + x2: 2.0, + y2: 0.0, + z2: gap, + radius: 0.001, + }), + ], + ground: GeometricGround::default(), + gpflag: 0, + transforms: GeometryTransforms::default(), + }, + None, + ) + .expect("build_mesh must succeed"); + + // Mesh is produced — warning, not error. + assert_eq!(mesh.segments.len(), 2); + + // NearCoincidentEndpoints warning emitted. + let warn_vec = warnings.into_vec(); + assert!(!warn_vec.is_empty(), "expected at least one warning"); + let has_near_warn = warn_vec + .iter() + .any(|w| w.kind == GeometryWarningKind::NearCoincidentEndpoints); + assert!( + has_near_warn, + "expected NearCoincidentEndpoints warning, got: {:?}", + warn_vec + ); + + // No junction created at the near-coincident gap. + assert!( + mesh.junctions.is_empty(), + "near-coincident gap must not form a junction" + ); +} diff --git a/crates/arcanum-geometry/src/transforms.rs b/crates/arcanum-geometry/src/transforms.rs new file mode 100644 index 0000000..8614b99 --- /dev/null +++ b/crates/arcanum-geometry/src/transforms.rs @@ -0,0 +1,189 @@ +// transforms.rs — GS scale and GM operations (Step 5) +// +// GS: uniform scale applied to all wire coordinates (not wire radii). +// GM: rotation (ROX → ROY → ROZ) then translation, optionally replicated. +// n_copies == 0 → transform tagged segments in place. +// n_copies > 0 → keep originals, append N transformed copies. +// +// Rotation convention: Rx(rox) → Ry(roy) → Rz(roz), angles in degrees. + +use std::f64::consts::PI; + +use arcanum_nec_import::{GeometryTransforms, WireDescription}; +use nalgebra::{Matrix3, Vector3}; + +use crate::errors::GeometryError; +use crate::mesh::{CurveParams, Segment, TagMap}; + +// ───────────────────────────────────────────────────────────────────────────── +// Public entry point +// ───────────────────────────────────────────────────────────────────────────── + +pub(crate) fn apply( + segments: &mut Vec, + tag_map: &mut TagMap, + transforms: &GeometryTransforms, + _wires: &[WireDescription], +) -> Result<(), GeometryError> { + // Step 5a: GS scale (applied before GM). + if let Some(scale) = transforms.gs_scale { + for seg in segments.iter_mut() { + scale_segment(seg, scale); + } + } + + // Step 5b: GM operations, in deck order. + for op in &transforms.gm_ops { + let rot = rotation_matrix(op.rot_x, op.rot_y, op.rot_z); + let trans = Vector3::new(op.trans_x, op.trans_y, op.trans_z); + + if op.n_copies == 0 { + // Transform tagged segments in place. + for seg in segments.iter_mut() { + if op.tag == 0 || seg.tag == op.tag { + transform_segment(seg, &rot, &trans); + } + } + } else { + // Generate n_copies new copies; keep originals unchanged. + let source_segments: Vec = segments + .iter() + .filter(|s| op.tag == 0 || s.tag == op.tag) + .cloned() + .collect(); + + for copy_k in 1..=(op.n_copies as usize) { + // Each copy k applies the base transform k times cumulatively. + let rot_k = rotation_matrix( + op.rot_x * copy_k as f64, + op.rot_y * copy_k as f64, + op.rot_z * copy_k as f64, + ); + let trans_k = trans * copy_k as f64; + let new_tag_offset = copy_k as u32 * op.tag_increment; + + let base_index = segments.len(); + for (local_k, src) in source_segments.iter().enumerate() { + let mut new_seg = src.clone(); + new_seg.tag = src.tag + new_tag_offset; + new_seg.segment_index = base_index + local_k; + new_seg.wire_index = base_index + local_k; // placeholder + transform_segment(&mut new_seg, &rot_k, &trans_k); + segments.push(new_seg); + } + + // Register the new copy in the tag map. + if !source_segments.is_empty() { + let first = base_index; + let last = base_index + source_segments.len() - 1; + let new_tag = source_segments[0].tag + new_tag_offset; + tag_map.insert(new_tag, first, last); + } + } + } + } + + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// GS: uniform coordinate scale (wire_radius is NOT scaled) +// ───────────────────────────────────────────────────────────────────────────── + +fn scale_segment(seg: &mut Segment, s: f64) { + match &mut seg.curve { + CurveParams::Linear(p) => { + p.start *= s; + p.end *= s; + } + CurveParams::Arc(p) => { + p.start *= s; + p.end *= s; + p.radius *= s; + } + CurveParams::Helix(p) => { + p.start *= s; + p.end *= s; + p.total_length *= s; + p.radius_start *= s; + p.radius_end *= s; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// GM: rotation then translation applied to Cartesian endpoints +// ───────────────────────────────────────────────────────────────────────────── + +fn transform_segment(seg: &mut Segment, rot: &Matrix3, trans: &Vector3) { + match &mut seg.curve { + CurveParams::Linear(p) => { + p.start = rot * p.start + trans; + p.end = rot * p.end + trans; + } + CurveParams::Arc(p) => { + // After GM the arc is in a rotated plane. theta1/theta2 are now + // approximate; Phase 2 uses the Cartesian endpoints. + p.start = rot * p.start + trans; + p.end = rot * p.end + trans; + } + CurveParams::Helix(p) => { + // After GM the helix is in a rotated frame. + // Phase 2 uses the precomputed Cartesian endpoints. + p.start = rot * p.start + trans; + p.end = rot * p.end + trans; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rotation matrix: Rz(roz) * Ry(roy) * Rx(rox), angles in degrees. +// NEC-2 convention: rotations applied in order ROX → ROY → ROZ. +// ───────────────────────────────────────────────────────────────────────────── + +fn rotation_matrix(rox_deg: f64, roy_deg: f64, roz_deg: f64) -> Matrix3 { + let rx = deg_to_rad(rox_deg); + let ry = deg_to_rad(roy_deg); + let rz = deg_to_rad(roz_deg); + + let r_x = Matrix3::new( + 1.0, + 0.0, + 0.0, + 0.0, + rx.cos(), + -rx.sin(), + 0.0, + rx.sin(), + rx.cos(), + ); + let r_y = Matrix3::new( + ry.cos(), + 0.0, + ry.sin(), + 0.0, + 1.0, + 0.0, + -ry.sin(), + 0.0, + ry.cos(), + ); + let r_z = Matrix3::new( + rz.cos(), + -rz.sin(), + 0.0, + rz.sin(), + rz.cos(), + 0.0, + 0.0, + 0.0, + 1.0, + ); + + r_z * r_y * r_x +} + +#[inline] +fn deg_to_rad(deg: f64) -> f64 { + deg * PI / 180.0 +} diff --git a/crates/arcanum-py/src/lib.rs b/crates/arcanum-py/src/lib.rs index 9f9b9ff..19135ae 100644 --- a/crates/arcanum-py/src/lib.rs +++ b/crates/arcanum-py/src/lib.rs @@ -10,6 +10,7 @@ // rustc flags as unexpected. This is a known pyo3 issue; allow it crate-wide. #![allow(unexpected_cfgs)] +use arcanum_geometry as geo; use arcanum_nec_import as nec; use pyo3::prelude::*; @@ -842,6 +843,368 @@ fn parse_file(py: Python<'_>, path: &str) -> PyResult<(PySimulationInput, Vec, e: geo::GeometryError) -> PyErr { + let err = GeometryError::new_err(format!( + "[wire {}] {}: {}", + e.wire_index, + e.kind.as_str(), + e.message + )); + { + let exc = err.value_bound(py); + let _ = exc.setattr("kind", e.kind.as_str()); + let _ = exc.setattr("wire_index", e.wire_index); + let _ = exc.setattr("message", e.message.as_str()); + } + err +} + +// ───────────────────────────────────────────────────────────────────────────── +// GeometryWarning +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "GeometryWarning")] +struct PyGeometryWarning { + inner: geo::GeometryWarning, +} + +#[pymethods] +impl PyGeometryWarning { + /// Warning category string (e.g. 'NearCoincidentEndpoints'). + #[getter] + fn kind(&self) -> &str { + self.inner.kind.as_str() + } + + /// Human-readable description. + #[getter] + fn message(&self) -> &str { + &self.inner.message + } + + fn __repr__(&self) -> String { + format!( + "GeometryWarning(kind={:?}, message={:?})", + self.inner.kind.as_str(), + self.inner.message + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Segment +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "Segment")] +struct PySegment { + inner: geo::Segment, +} + +#[pymethods] +impl PySegment { + /// Precomputed start point as (x, y, z) in meters. + #[getter] + fn start(&self) -> (f64, f64, f64) { + let p = self.inner.start(); + (p.x, p.y, p.z) + } + + /// Precomputed end point as (x, y, z) in meters. + #[getter] + fn end(&self) -> (f64, f64, f64) { + let p = self.inner.end(); + (p.x, p.y, p.z) + } + + /// Wire cross-section radius in meters. Not scaled by GS. + #[getter] + fn wire_radius(&self) -> f64 { + self.inner.wire_radius + } + + /// NEC wire tag number. + #[getter] + fn tag(&self) -> u32 { + self.inner.tag + } + + /// Global index of this segment in the mesh segment list. + #[getter] + fn segment_index(&self) -> usize { + self.inner.segment_index + } + + /// Index of the wire (GW/GA/GH card) this segment belongs to. + #[getter] + fn wire_index(&self) -> usize { + self.inner.wire_index + } + + /// True if this is a PEC ground image segment (not addressable by EX/LD). + #[getter] + fn is_image(&self) -> bool { + self.inner.is_image + } + + /// Curve type string: 'Linear', 'Arc', or 'Helix'. + #[getter] + fn curve_type(&self) -> &str { + match &self.inner.curve { + geo::CurveParams::Linear(_) => "Linear", + geo::CurveParams::Arc(_) => "Arc", + geo::CurveParams::Helix(_) => "Helix", + } + } + + fn __repr__(&self) -> String { + let s = self.inner.start(); + let e = self.inner.end(); + format!( + "Segment(index={}, tag={}, type={}, ({:.4},{:.4},{:.4})→({:.4},{:.4},{:.4}){})", + self.inner.segment_index, + self.inner.tag, + match &self.inner.curve { + geo::CurveParams::Linear(_) => "Linear", + geo::CurveParams::Arc(_) => "Arc", + geo::CurveParams::Helix(_) => "Helix", + }, + s.x, + s.y, + s.z, + e.x, + e.y, + e.z, + if self.inner.is_image { " [image]" } else { "" } + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Junction +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "Junction")] +struct PyJunction { + inner: geo::Junction, +} + +#[pymethods] +impl PyJunction { + /// Unique index of this junction. + #[getter] + fn junction_index(&self) -> usize { + self.inner.junction_index + } + + /// All segment endpoints at this junction as list of (segment_index, side) tuples. + /// side is 'Start' or 'End'. + #[getter] + fn endpoints(&self) -> Vec<(usize, String)> { + self.inner + .endpoints + .iter() + .map(|ep| { + let side = match ep.side { + geo::EndpointSide::Start => "Start", + geo::EndpointSide::End => "End", + }; + (ep.segment_index, side.to_string()) + }) + .collect() + } + + /// True if this junction is a self-loop (start and end of the same wire). + #[getter] + fn is_self_loop(&self) -> bool { + self.inner.is_self_loop + } + + fn __repr__(&self) -> String { + format!( + "Junction(index={}, endpoints={}, is_self_loop={})", + self.inner.junction_index, + self.inner.endpoints.len(), + self.inner.is_self_loop + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// GroundDescriptor +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "GroundDescriptor")] +struct PyGroundDescriptor { + inner: geo::GroundDescriptor, +} + +#[pymethods] +impl PyGroundDescriptor { + /// Ground type string: 'None', 'Lossy', or 'PEC'. + #[getter] + fn ground_type(&self) -> &str { + match self.inner.ground_type { + geo::GroundType::None => "None", + geo::GroundType::Lossy => "Lossy", + geo::GroundType::PEC => "PEC", + } + } + + /// Electrical conductivity in S/m. None for PEC or free space. + #[getter] + fn conductivity(&self) -> Option { + self.inner.conductivity + } + + /// Relative permittivity εr. None for PEC or free space. + #[getter] + fn permittivity(&self) -> Option { + self.inner.permittivity + } + + /// True if Phase 1 generated PEC image segments for this mesh. + #[getter] + fn images_generated(&self) -> bool { + self.inner.images_generated + } + + fn __repr__(&self) -> String { + format!( + "GroundDescriptor(ground_type={:?}, images_generated={})", + match self.inner.ground_type { + geo::GroundType::None => "None", + geo::GroundType::Lossy => "Lossy", + geo::GroundType::PEC => "PEC", + }, + self.inner.images_generated + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mesh +// ───────────────────────────────────────────────────────────────────────────── + +#[pyclass(name = "Mesh")] +struct PyMesh { + inner: geo::Mesh, +} + +#[pymethods] +impl PyMesh { + /// All segments in order: real segments first, image segments last. + #[getter] + fn segments(&self) -> Vec { + self.inner + .segments + .iter() + .cloned() + .map(|s| PySegment { inner: s }) + .collect() + } + + /// All junctions detected in the mesh. + #[getter] + fn junctions(&self) -> Vec { + self.inner + .junctions + .iter() + .cloned() + .map(|j| PyJunction { inner: j }) + .collect() + } + + /// Ground plane descriptor. + #[getter] + fn ground(&self) -> PyGroundDescriptor { + PyGroundDescriptor { + inner: self.inner.ground.clone(), + } + } + + /// Tag map entries as (tag, first_segment_index, last_segment_index) tuples. + /// Image segments are excluded. + #[getter] + fn tag_entries(&self) -> Vec<(u32, usize, usize)> { + self.inner.tag_map.iter().collect() + } + + /// Total number of segments (real + image). + #[getter] + fn segment_count(&self) -> usize { + self.inner.segments.len() + } + + /// Number of real (non-image) segments. + #[getter] + fn real_segment_count(&self) -> usize { + self.inner.real_segment_count() + } + + /// Number of PEC image segments. + #[getter] + fn image_segment_count(&self) -> usize { + self.inner.image_segment_count() + } + + fn __repr__(&self) -> String { + format!( + "Mesh(segments={}, real={}, images={}, junctions={})", + self.inner.segments.len(), + self.inner.real_segment_count(), + self.inner.image_segment_count(), + self.inner.junctions.len() + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Geometry functions +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a discretized segment mesh from a parsed MeshInput. +/// +/// Returns ``(Mesh, [GeometryWarning, ...])`` on success. +/// Raises ``GeometryError`` on any hard error (zero-length wire, etc.). +/// +/// ``ground_electrical`` carries the lossy GN card parameters (conductivity, +/// permittivity). Pass ``None`` (the default) for PEC or free-space models. +#[pyfunction] +#[pyo3(signature = (mesh_input, ground_electrical=None))] +fn build_mesh( + py: Python<'_>, + mesh_input: &PyMeshInput, + ground_electrical: Option<&PyGroundElectrical>, +) -> PyResult<(PyMesh, Vec)> { + let ge = ground_electrical.map(|g| g.inner.clone()); + match geo::build_mesh(mesh_input.inner.clone(), ge) { + Ok((mesh, warnings)) => { + let py_warnings = warnings + .into_vec() + .into_iter() + .map(|w| PyGeometryWarning { inner: w }) + .collect(); + Ok((PyMesh { inner: mesh }, py_warnings)) + } + Err(e) => Err(geo_err_to_pyerr(py, e)), + } +} + // ───────────────────────────────────────────────────────────────────────────── // Module registration // ───────────────────────────────────────────────────────────────────────────── @@ -851,8 +1214,9 @@ fn parse_file(py: Python<'_>, path: &str) -> PyResult<(PySimulationInput, Vec) -> PyResult<()> { let py = m.py(); - // Exception type + // Exception types m.add("ParseError", py.get_type_bound::())?; + m.add("GeometryError", py.get_type_bound::())?; // NEC import types m.add_class::()?; @@ -875,5 +1239,15 @@ fn arcanum(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(parse, m)?)?; m.add_function(wrap_pyfunction!(parse_file, m)?)?; + // Geometry types + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Geometry functions + m.add_function(wrap_pyfunction!(build_mesh, m)?)?; + Ok(()) } diff --git a/docs/phase1-geometry/design.md b/docs/phase1-geometry/design.md index 434c2b8..69f8dfe 100644 --- a/docs/phase1-geometry/design.md +++ b/docs/phase1-geometry/design.md @@ -3,7 +3,7 @@ **Project:** Arcanum **Document:** `docs/phase1-geometry/design.md` **Status:** DRAFT -**Revision:** 0.1 +**Revision:** 0.2 --- @@ -93,7 +93,18 @@ One exception: if N is specified as 0 in the input deck, Phase 1 must return a p A junction is a point in space where two or more wire endpoints meet. Junctions are how antenna structures are connected. Such as a T-match, a driven element fed at the center, a Yagi boom with elements attached. NEC files do not declare junctions explicitly. They are inferred by finding wire endpoints that are geometrically coincident within a tolerance. -### 5.2 Junction Detection +### 5.2 What a Junction Is Not + +Not every pair of coincident endpoints qualifies as a junction. Within a single wire card (`GW`, `GA`, or `GH`), the end of segment k and the start of segment k+1 are always connected — that is the definition of discretization. These **intra-wire adjacent boundaries** are implicit in the segment ordering and are never recorded as explicit junctions in the junction map. + +Only two cases produce a junction record: + +1. **Cross-wire connection** — an endpoint of one wire card is geometrically coincident with an endpoint of a different wire card. +2. **Self-loop closure** — the last segment end of a wire is coincident with its own first segment start (a closed loop antenna such as `GA` with 360°). + +This distinction matters for valence counting: the midpoint of a 2-segment dipole (`GW 1 2 ...`) has no junction record even though two segment endpoints meet there. It is addressable by an `EX` card via `(tag, segment)` directly. + +### 5.3 Junction Detection Two endpoints are considered coincident if their distance is less than a tolerance `ε`. The default tolerance is: @@ -101,19 +112,19 @@ Two endpoints are considered coincident if their distance is less than a toleran ε = min(radius_a, radius_b) × 0.01 ``` -This is intentionally conservative. Phase 1 should warn when endpoints are close but not coincident (distance between `ε` and `10ε`), as this often indicates a modeling error in the input deck. Becasue we are helpful. +This is intentionally conservative. Phase 1 should warn when endpoints are close but not coincident (distance between `ε` and `10ε`), as this often indicates a modeling error in the input deck. Because we are helpful. -### 5.3 The Junction Map +### 5.4 The Junction Map The junction map records, for each junction point, the list of segment endpoints that meet there. This is the connectivity graph Phase 2 uses to enforce current continuity at junctions (Kirchhoff's current law in the MoM formulation). Each junction is assigned a unique index. The map is bidirectional. Given a segment endpoint, you can look up which junction it belongs to. Given a junction, you can enumerate all connected segment endpoints. -### 5.4 Degenerate Cases +### 5.5 Degenerate Cases - **Isolated wire endpoint** — an endpoint that belongs to no junction. Valid — monopoles and open-ended dipoles have free endpoints. - **Two-wire junction** — the normal case for connected antennas. -- **Three-or-more-wire junction** — valid, occurs in log-periodic arrays, feed networks, and complex structures. Phase 1 must handle arbitrary valence. +- **Three-or-more-wire junction** — valid, occurs in log-periodic arrays, feed networks, and complex structures. Phase 1 must handle arbitrary valence. Valence is counted in *wires*, not segment endpoints: a wire that passes through a junction (its midpoint is at the junction point) contributes 1 to valence even though it places 2 segment endpoints into the junction record. - **Self-loop** — a wire whose start and end endpoints are coincident. Valid for loop antennas. Phase 1 should detect and flag these explicitly rather than treating them as a two-wire junction. Any other problem cases that may come up in development that we missed would go in the above list. diff --git a/docs/phase1-geometry/validation.md b/docs/phase1-geometry/validation.md index 0550f1b..5e38791 100644 --- a/docs/phase1-geometry/validation.md +++ b/docs/phase1-geometry/validation.md @@ -3,7 +3,7 @@ **Project:** Arcanum **Document:** `docs/phase1-geometry/validation.md` **Status:** DRAFT -**Revision:** 0.2 +**Revision:** 0.4 --- @@ -44,7 +44,7 @@ A center-fed half-wave dipole along the z-axis, 0.5 m long, 1 mm radius, 2 segme - Segment 0: start (0, 0, -0.25), end (0, 0, 0.0), length 0.25 m - Segment 1: start (0, 0, 0.0), end (0, 0, 0.25), length 0.25 m - Both segments: radius 0.001 m, material PEC, tag 1 -- Junction at (0, 0, 0.0) connecting segment 0 end to segment 1 start +- No junctions — the midpoint at (0, 0, 0.0) is an intra-wire adjacent boundary between segments 0 and 1 of the same `GW` card. Intra-wire adjacency is implicit in the discretization and is never recorded as a junction. See `design.md` Section 5.2. - Two free endpoints: (0, 0, -0.25) and (0, 0, 0.25) - Ground descriptor: GroundType::None @@ -85,9 +85,10 @@ Two collinear wires sharing an endpoint at (1, 0, 0). **Expected Mesh:** - Segment count: 6 -- Junction at (1.0, 0.0, 0.0) connecting segment 2 (end) to segment 3 (start) +- Junction at (1.0, 0.0, 0.0) connecting segment 2 end to segment 3 start - Junction map: 1 junction, valence 2 - Free endpoints: (0, 0, 0) and (2, 0, 0) +- *(Note: the intra-wire boundaries at x = 1/3 and x = 2/3 within wire 1, and x = 4/3 and x = 5/3 within wire 2, are implicit adjacencies and are not junctions.)* --- @@ -105,8 +106,9 @@ Three wires meeting at the origin, one along each axis. **Expected Mesh:** - Segment count: 6 -- Junction at (0, 0, 0) with valence 3 — wire 1 midpoint, wire 2 start, wire 3 start -- Junction map correctly identifies all three segment endpoints at origin as the same junction +- Junction at (0, 0, 0) with valence 3 (3 wires meet here): wire 1 passes through (contributing seg 0 End and seg 1 Start), wire 2 starts here (seg 2 Start), wire 3 starts here (seg 4 Start). The junction record holds 4 segment endpoints. +- Valence is counted in wires, not segment endpoints. Wire 1's midpoint counts as 1 wire connection even though it places 2 endpoints into the junction record. See `design.md` Section 5.5. +- Junction map correctly identifies all four segment endpoints at the origin as belonging to the same junction. **Note:** This case validates that Phase 1 correctly handles junctions of valence > 2. @@ -226,10 +228,12 @@ An arc that almost closes (359° instead of 360°). The gap between start and en **Input:** ``` -GH 1 8 0.0628 0.05 0.05 0.001 0.001 0.0 +GH 1 8 0.0628 0.0628 0.05 0.05 0.001 GE 0 ``` +Fields: tag=1, NS=8, S(pitch)=0.0628 m, HL(total_length)=0.0628 m, A1=0.05 m, A2=0.05 m, RAD=0.001 m. n_turns = HL/S = 1.0. + A single-turn helix: pitch 0.0628 m (≈ λ/10 at 480 MHz), radius 0.05 m, 8 segments, 1 mm wire radius. Simple but effective test. **Expected Mesh:** @@ -247,11 +251,13 @@ A single-turn helix: pitch 0.0628 m (≈ λ/10 at 480 MHz), radius 0.05 m, 8 seg **Input:** ``` -GH 1 40 0.0628 0.05 0.05 0.001 0.001 0.0 +GH 1 40 0.0628 0.314 0.05 0.05 0.001 GE 0 ``` -A 5-turn helix (40 segments at 8 per turn), same parameters as V-HEL-001. +Fields: tag=1, NS=40, S(pitch)=0.0628 m, HL(total_length)=0.314 m, A1=0.05 m, A2=0.05 m, RAD=0.001 m. n_turns = HL/S = 5.0. + +A 5-turn helix (40 segments at 8 per turn), same pitch and radius as V-HEL-001. **Expected Mesh:** - Segment count: 40 @@ -269,11 +275,13 @@ A 5-turn helix (40 segments at 8 per turn), same parameters as V-HEL-001. **Input:** ``` -GH 1 16 0.0628 0.05 0.05 0.001 0.001 0.0 +GH 1 16 0.0628 0.1256 0.05 0.05 0.001 GN 1 GE 1 ``` +Fields: tag=1, NS=16, S(pitch)=0.0628 m, HL(total_length)=0.1256 m, A1=0.05 m, A2=0.05 m, RAD=0.001 m. n_turns = HL/S = 2.0. + A 2-turn helix above a PEC ground plane. **Expected Mesh:** @@ -281,6 +289,7 @@ A 2-turn helix above a PEC ground plane. - Image segments have z-coordinates negated relative to originals - Image segments are flagged as images in the tag map (not addressable by EX/LD cards) - Ground descriptor: GroundType::PEC, images_generated: true +- Junctions: 1 — real segment 0 Start and image segment 16 Start share the helix feed point at z = 0 (ground plane contact). Adjacent image segments belong to the same wire and are excluded by the intra-wire adjacency rule. --- @@ -357,6 +366,7 @@ A vertical wire above a PEC ground plane (z = 0 to z = 0.5). - Total segment count: 8 - Ground descriptor: GroundType::PEC, images_generated: true - Image segment endpoints are exact reflections: if original has endpoint (x, y, z), image has (x, y, -z) +- Junctions: 1 — real segment 0 Start and image segment 4 Start share the ground-plane contact point (0, 0, 0). Adjacent image segments belong to the same wire and are excluded by the intra-wire adjacency rule. --- @@ -396,6 +406,7 @@ A horizontal wire lying exactly in the z = 0 ground plane. Trick! - No image segments generated (wire is its own image) - Warning emitted noting wire lies in ground plane - Segment count: 4 (not 8) +- Junctions: 0 — no images means no real-to-image connections; single wire has no cross-wire connections --- @@ -458,8 +469,6 @@ Each case must be implemented as a Rust unit test in the Phase 1 test module. Te 4. For hard error cases, assert that the function returns `Err(...)` not `Ok(...)` 5. For warning cases, assert that `ParseWarnings` is non-empty and contains the expected warning type -Convergence plots, demonstrating that geometric continuity and endpoint accuracy hold as N increases, are required for V-HEL-002 and V-ARC-001 before Phase 1 implementation is marked complete. - --- ## 11. References diff --git a/examples/mesh_inspect.py b/examples/mesh_inspect.py new file mode 100644 index 0000000..c8e4380 --- /dev/null +++ b/examples/mesh_inspect.py @@ -0,0 +1,246 @@ +"""mesh_inspect.py — parse a NEC deck, build the Phase 1 mesh, and summarise. + +Usage: + python examples/mesh_inspect.py path/to/deck.nec + +Writes a structured summary to stdout: + - NEC parse overview (wires, ground, transforms) + - Mesh summary (segment counts, ground descriptor) + - Tag map + - Junctions (if any) + - Segment table + - Geometry and parse warnings + +Requires the arcanum extension to be installed (e.g. via `maturin develop`). +""" + +import math +import sys + +# Ensure Unicode line-drawing characters render correctly on Windows. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +import arcanum + +# ── formatting helpers ──────────────────────────────────────────────────────── + +SECTION = "━" * 60 +DIVIDER = "─" * 40 + + +def heading(title: str) -> None: + print(f"\n{SECTION}") + print(f" {title}") + print(SECTION) + + +def subheading(title: str) -> None: + print(f"\n {DIVIDER}") + print(f" {title}") + print(f" {DIVIDER}") + + +def field(label: str, value, indent: int = 4) -> None: + pad = " " * indent + print(f"{pad}{label:<28}{value}") + + +def fmt_pt(xyz) -> str: + x, y, z = xyz + return f"({x:+.6f}, {y:+.6f}, {z:+.6f})" + + +def seg_length(seg) -> float: + sx, sy, sz = seg.start + ex, ey, ez = seg.end + return math.sqrt((ex - sx) ** 2 + (ey - sy) ** 2 + (ez - sz) ** 2) + + +# ── NEC parse summary ───────────────────────────────────────────────────────── + +def print_nec_summary(sim, path: str) -> None: + mesh = sim.mesh_input + wires = mesh.wires + heading(f"NEC INPUT — {path}") + field("wires declared", len(wires)) + field("ground type", mesh.ground.ground_type) + + transforms = mesh.transforms + if transforms.gs_scale is not None: + field("GS scale factor", transforms.gs_scale) + if transforms.gm_ops: + field("GM operations", len(transforms.gm_ops)) + + freqs = sim.frequencies + if freqs: + mhz_list = ", ".join(f"{f / 1e6:.3f}" for f in freqs[:4]) + suffix = f" …+{len(freqs) - 4} more" if len(freqs) > 4 else "" + field("frequencies MHz", mhz_list + suffix) + + if sim.sources: + for s in sim.sources: + field(f" source tag={s.tag} seg={s.segment}", f"EX type {s.ex_type}") + + if sim.ground_electrical: + ge = sim.ground_electrical + field("ground εr", ge.permittivity) + field("ground σ S/m", ge.conductivity) + + +# ── mesh summary ────────────────────────────────────────────────────────────── + +def print_mesh_summary(mesh) -> None: + heading("MESH SUMMARY") + field("total segments", mesh.segment_count) + field("real segments", mesh.real_segment_count) + if mesh.image_segment_count: + field("image segments (PEC)", mesh.image_segment_count) + field("junctions", len(mesh.junctions)) + + g = mesh.ground + field("ground type", g.ground_type) + if g.images_generated: + field("PEC images generated", "yes") + if g.conductivity is not None: + field("conductivity S/m", g.conductivity) + if g.permittivity is not None: + field("permittivity εr", g.permittivity) + + +# ── tag map ─────────────────────────────────────────────────────────────────── + +def print_tag_map(mesh) -> None: + entries = mesh.tag_entries + if not entries: + return + heading(f"TAG MAP ({len(entries)} wire{'s' if len(entries) != 1 else ''})") + print(f" {'tag':>4} {'first seg':>9} {'last seg':>8} {'count':>5}") + print(f" {'───':>4} {'─────────':>9} {'────────':>8} {'─────':>5}") + for tag, first, last in entries: + count = last - first + 1 + print(f" {tag:>4} {first:>9} {last:>8} {count:>5}") + + +# ── junctions ───────────────────────────────────────────────────────────────── + +def print_junctions(mesh) -> None: + junctions = mesh.junctions + if not junctions: + return + heading(f"JUNCTIONS ({len(junctions)})") + segments = mesh.segments + for j in junctions: + loop_label = " [self-loop]" if j.is_self_loop else "" + subheading(f"Junction {j.junction_index} — {len(j.endpoints)} endpoints{loop_label}") + + # Collect distinct wire indices contributing to this junction (valence). + wire_indices = {segments[si].wire_index for si, _side in j.endpoints} + field("valence (wires)", len(wire_indices)) + + # Print position from the first endpoint's segment start/end. + first_si, first_side = j.endpoints[0] + pos = segments[first_si].start if first_side == "Start" else segments[first_si].end + field("position m", fmt_pt(pos)) + + for si, side in j.endpoints: + seg = segments[si] + field(f" seg {si} {side}", f"tag={seg.tag} wire={seg.wire_index}") + + +# ── segment table ───────────────────────────────────────────────────────────── + +# Maximum segments to print individually before switching to a compact summary. +_SEG_DETAIL_LIMIT = 40 + + +def print_segments(mesh) -> None: + segments = mesh.segments + total = len(segments) + heading(f"SEGMENTS ({total} total)") + + if total > _SEG_DETAIL_LIMIT: + print(f" (showing first {_SEG_DETAIL_LIMIT} of {total} segments)\n") + + hdr = f" {'idx':>4} {'tag':>3} {'type':>6} {'wire':>4} {'start (x,y,z) m':<36} {'end (x,y,z) m':<36} {'len m':>9}" + bar = f" {'────':>4} {'───':>3} {'──────':>6} {'────':>4} {'─' * 36:<36} {'─' * 36:<36} {'─────────':>9}" + print(hdr) + print(bar) + + for seg in segments[:_SEG_DETAIL_LIMIT]: + image_flag = "*" if seg.is_image else " " + length = seg_length(seg) + print( + f" {seg.segment_index:>4}{image_flag} {seg.tag:>3} {seg.curve_type:>6} {seg.wire_index:>4}" + f" {fmt_pt(seg.start):<36} {fmt_pt(seg.end):<36} {length:>9.6f}" + ) + + if total > _SEG_DETAIL_LIMIT: + shown = _SEG_DETAIL_LIMIT + remaining = total - shown + real_rem = sum(1 for s in segments[shown:] if not s.is_image) + img_rem = remaining - real_rem + parts = [] + if real_rem: + parts.append(f"{real_rem} real") + if img_rem: + parts.append(f"{img_rem} image") + print(f"\n … {remaining} more segments ({', '.join(parts)})") + + if any(s.is_image for s in segments): + print("\n * = PEC image segment") + + +# ── warnings ────────────────────────────────────────────────────────────────── + +def print_geometry_warnings(geo_warnings) -> None: + if not geo_warnings: + return + heading(f"GEOMETRY WARNINGS ({len(geo_warnings)})") + for w in geo_warnings: + field(w.kind, w.message) + + +def print_parse_warnings(parse_warnings) -> None: + if not parse_warnings: + return + heading(f"PARSE WARNINGS ({len(parse_warnings)})") + for w in parse_warnings: + field(f"line {w.line} {w.kind}", w.message) + + +# ── main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + path = sys.argv[1] + + try: + sim, parse_warnings = arcanum.parse_file(path) + except arcanum.ParseError as e: + print(f"parse error: {e}", file=sys.stderr) + return 1 + + ge = sim.ground_electrical # None for free space / PEC + try: + mesh, geo_warnings = arcanum.build_mesh(sim.mesh_input, ge) + except arcanum.GeometryError as e: + print(f"geometry error: {e}", file=sys.stderr) + return 1 + + print_nec_summary(sim, path) + print_mesh_summary(mesh) + print_tag_map(mesh) + print_junctions(mesh) + print_segments(mesh) + print_geometry_warnings(geo_warnings) + print_parse_warnings(parse_warnings) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/arcanum/arcanum.pdb b/python/arcanum/arcanum.pdb deleted file mode 100644 index f87b46b..0000000 Binary files a/python/arcanum/arcanum.pdb and /dev/null differ diff --git a/python/arcanum/geometry.py b/python/arcanum/geometry.py index 6407d1a..826df5f 100644 --- a/python/arcanum/geometry.py +++ b/python/arcanum/geometry.py @@ -1,6 +1,46 @@ # arcanum.geometry # -# Python helpers for Phase 1 — Geometry Discretization. -# Core implementation is in crates/arcanum-geometry (Rust). +# Python interface for Phase 1 — Geometry Discretization. +# The implementation is in crates/arcanum-geometry (Rust), exposed via the +# native extension built by crates/arcanum-py. # -# Stub. Populated when Phase 1 implementation begins. +# Public API (all importable directly from arcanum): +# +# arcanum.build_mesh(mesh_input, ground_electrical=None) +# -> (Mesh, list[GeometryWarning]) +# +# arcanum.Mesh +# .segments -> list[Segment] +# .junctions -> list[Junction] +# .ground -> GroundDescriptor +# .tag_entries -> list[(tag, first, last)] +# .segment_count -> int +# .real_segment_count -> int +# .image_segment_count -> int +# +# arcanum.Segment +# .start, .end -> (x, y, z) meters +# .wire_radius -> float meters +# .tag -> int +# .segment_index -> int +# .wire_index -> int +# .is_image -> bool +# .curve_type -> 'Linear' | 'Arc' | 'Helix' +# +# arcanum.Junction +# .junction_index -> int +# .endpoints -> list[(segment_index, 'Start'|'End')] +# .is_self_loop -> bool +# +# arcanum.GroundDescriptor +# .ground_type -> 'None' | 'Lossy' | 'PEC' +# .conductivity -> float | None S/m +# .permittivity -> float | None εr +# .images_generated -> bool +# +# arcanum.GeometryWarning +# .kind -> 'NearCoincidentEndpoints' | 'WireInGroundPlane' +# .message -> str +# +# arcanum.GeometryError (exception) +# .kind, .wire_index, .message