Skip to main content

oxiphysics_materials/additive_manufacturing/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7use std::f64::consts::PI;
8
9/// Geometric parameters for a single AM layer.
10#[derive(Debug, Clone)]
11pub struct LayerModel {
12    /// Layer thickness (m).
13    pub layer_thickness: f64,
14    /// Hatch spacing between adjacent scan tracks (m).
15    pub hatch_spacing: f64,
16    /// Nominal laser spot radius (m).
17    pub spot_radius: f64,
18    /// Scan speed (m/s).
19    pub scan_speed: f64,
20    /// Laser power (W).
21    pub laser_power: f64,
22    /// Scanning strategy applied to this layer.
23    pub scan_strategy: ScanStrategy,
24}
25impl LayerModel {
26    /// Construct a new [`LayerModel`].
27    pub fn new(
28        layer_thickness: f64,
29        hatch_spacing: f64,
30        spot_radius: f64,
31        scan_speed: f64,
32        laser_power: f64,
33        scan_strategy: ScanStrategy,
34    ) -> Self {
35        Self {
36            layer_thickness,
37            hatch_spacing,
38            spot_radius,
39            scan_speed,
40            laser_power,
41            scan_strategy,
42        }
43    }
44    /// Volumetric energy density (J/m³), often written as *E_v*.
45    ///
46    /// ```text
47    /// E_v = P / (v · h · t)
48    /// ```
49    /// where *P* = power, *v* = scan speed, *h* = hatch spacing, *t* = layer thickness.
50    pub fn volumetric_energy_density(&self) -> f64 {
51        self.laser_power / (self.scan_speed * self.hatch_spacing * self.layer_thickness)
52    }
53    /// Linear energy density (J/m) — power divided by scan speed.
54    pub fn linear_energy_density(&self) -> f64 {
55        self.laser_power / self.scan_speed
56    }
57    /// Hatch overlap fraction (0–1).  Positive means tracks overlap.
58    ///
59    /// `overlap = 1 − hatch_spacing / (2 · spot_radius)`
60    pub fn hatch_overlap(&self) -> f64 {
61        1.0_f64 - self.hatch_spacing / (2.0_f64 * self.spot_radius)
62    }
63    /// Approximate number of scan tracks required to cover a square domain of
64    /// side `width` (m).
65    pub fn num_tracks(&self, width: f64) -> usize {
66        ((width / self.hatch_spacing).ceil() as usize).max(1)
67    }
68}
69/// Defect formation models for LPBF.
70#[derive(Debug, Clone)]
71pub struct DefectModels {
72    /// Measured relative density (0–1).
73    pub relative_density: f64,
74    /// Volumetric energy density (J/m³).
75    pub energy_density: f64,
76    /// Threshold energy density below which lack-of-fusion pores form (J/m³).
77    pub lof_threshold: f64,
78    /// Threshold energy density above which keyhole pores form (J/m³).
79    pub keyhole_threshold: f64,
80    /// Ultimate tensile strength of the matrix material (Pa).
81    pub uts: f64,
82    /// Fracture toughness of the matrix (Pa·√m).
83    pub fracture_toughness: f64,
84    /// Characteristic defect size (m) — used for cracking criterion.
85    pub defect_size: f64,
86}
87impl DefectModels {
88    /// Construct a [`DefectModels`] struct.
89    pub fn new(
90        relative_density: f64,
91        energy_density: f64,
92        lof_threshold: f64,
93        keyhole_threshold: f64,
94        uts: f64,
95        fracture_toughness: f64,
96        defect_size: f64,
97    ) -> Self {
98        Self {
99            relative_density,
100            energy_density,
101            lof_threshold,
102            keyhole_threshold,
103            uts,
104            fracture_toughness,
105            defect_size,
106        }
107    }
108    /// True if the energy density is below the lack-of-fusion threshold.
109    pub fn has_lack_of_fusion(&self) -> bool {
110        self.energy_density < self.lof_threshold
111    }
112    /// True if the energy density is above the keyhole threshold.
113    pub fn has_keyhole_porosity(&self) -> bool {
114        self.energy_density > self.keyhole_threshold
115    }
116    /// Porosity fraction estimated from the relative density.
117    pub fn porosity_fraction(&self) -> f64 {
118        (1.0_f64 - self.relative_density).max(0.0_f64)
119    }
120    /// Cracking criterion using a stress-intensity-factor approach.
121    ///
122    /// Returns `true` when the mode-I SIF exceeds the fracture toughness:
123    /// `K_I = σ_UTS · √(π · a) > K_IC`.
124    pub fn cracking_criterion(&self) -> bool {
125        let k_i = self.uts * (PI * self.defect_size).sqrt();
126        k_i >= self.fracture_toughness
127    }
128    /// Quality flag: process is in the "sweet spot" (no LOF, no keyhole).
129    pub fn in_process_window(&self) -> bool {
130        !self.has_lack_of_fusion() && !self.has_keyhole_porosity()
131    }
132}
133/// Overhang detection and support-structure estimation for AM parts.
134///
135/// Any surface facet inclined more than `max_overhang_angle` from the
136/// horizontal is considered self-supporting; steeper facets require support.
137#[derive(Debug, Clone)]
138pub struct SupportStructure {
139    /// Maximum self-supporting overhang angle from horizontal (radians).
140    /// Typical LPBF value is ~45° (π/4).
141    pub max_overhang_angle: f64,
142    /// Material density used to compute support mass (kg/m³).
143    pub material_density: f64,
144    /// Support fill fraction relative to solid (0–1).
145    /// Lattice supports typically use 0.05–0.20.
146    pub support_fill_fraction: f64,
147    /// Estimated support volume (m³) — populated by `estimate_support`.
148    pub support_volume_m3: f64,
149}
150impl SupportStructure {
151    /// Construct a [`SupportStructure`] model.
152    ///
153    /// # Arguments
154    /// * `max_overhang_angle_deg` — maximum self-supporting angle in **degrees**.
155    /// * `material_density` — kg/m³.
156    /// * `support_fill_fraction` — lattice density relative to solid (0–1).
157    pub fn new(
158        max_overhang_angle_deg: f64,
159        material_density: f64,
160        support_fill_fraction: f64,
161    ) -> Self {
162        Self {
163            max_overhang_angle: max_overhang_angle_deg.to_radians(),
164            material_density,
165            support_fill_fraction: support_fill_fraction.clamp(0.0_f64, 1.0_f64),
166            support_volume_m3: 0.0_f64,
167        }
168    }
169    /// Determine whether a facet with outward-normal vector `normal` (unit vector,
170    /// z component positive upward) requires support.
171    ///
172    /// A facet requires support when it is downward-facing and the angle
173    /// between its outward normal and the downward vertical is less than
174    /// `max_overhang_angle` (i.e. the facet tilts more toward horizontal
175    /// than the self-supporting limit).
176    pub fn requires_support(&self, normal: [f64; 3]) -> bool {
177        let nz = normal[2];
178        if nz >= 0.0_f64 {
179            return false;
180        }
181        let cos_a = (-nz).clamp(-1.0_f64, 1.0_f64);
182        let angle_from_down = cos_a.acos();
183        angle_from_down < self.max_overhang_angle
184    }
185    /// Estimate support volume from a bounding-box projection.
186    ///
187    /// Simplified model: support fills the volume between the part's lowest
188    /// unsupported surface and the build plate, scaled by `support_fill_fraction`.
189    ///
190    /// # Arguments
191    /// * `part_volume_m3` — volume of the part itself (m³).
192    /// * `unsupported_fraction` — fraction of the part's projected area that
193    ///   requires support (0–1), estimated from a geometry scan.
194    /// * `part_height_m` — build height of the part (m).
195    pub fn estimate_support(
196        &mut self,
197        part_volume_m3: f64,
198        unsupported_fraction: f64,
199        part_height_m: f64,
200    ) {
201        let projected_area = part_volume_m3 / part_height_m.max(1.0e-9_f64);
202        let support_area = projected_area * unsupported_fraction.clamp(0.0_f64, 1.0_f64);
203        self.support_volume_m3 =
204            support_area * (0.5_f64 * part_height_m) * self.support_fill_fraction;
205    }
206    /// Estimated support mass (kg).
207    pub fn support_mass_kg(&self) -> f64 {
208        self.support_volume_m3 * self.material_density
209    }
210    /// Material waste fraction: support mass relative to (part + support) mass.
211    ///
212    /// # Arguments
213    /// * `part_mass_kg` — mass of the finished part (kg).
214    pub fn waste_fraction(&self, part_mass_kg: f64) -> f64 {
215        let total = part_mass_kg + self.support_mass_kg();
216        if total < 1.0e-15_f64 {
217            return 0.0_f64;
218        }
219        self.support_mass_kg() / total
220    }
221}
222/// Characteristic dimensions of the melt pool (all in metres).
223#[derive(Debug, Clone, Copy)]
224pub struct MeltPoolGeometry {
225    /// Half-length of the melt pool along the scan direction (m).
226    pub half_length: f64,
227    /// Half-width of the melt pool (m).
228    pub half_width: f64,
229    /// Depth of the melt pool (m).
230    pub depth: f64,
231}
232impl MeltPoolGeometry {
233    /// Aspect ratio: depth / width.
234    pub fn aspect_ratio(&self) -> f64 {
235        self.depth / (2.0_f64 * self.half_width)
236    }
237    /// Volume of the melt pool modelled as a half-ellipsoid (m³).
238    pub fn volume(&self) -> f64 {
239        (2.0_f64 / 3.0_f64) * PI * self.half_length * self.half_width * self.depth
240    }
241}
242/// Simplified residual stress build-up model for LPBF.
243///
244/// Uses the temperature-gradient mechanism (TGM) and cool-down shrinkage
245/// to estimate in-plane longitudinal residual stress.
246#[derive(Debug, Clone)]
247pub struct ResidualStress {
248    /// Young's modulus of the build material (Pa).
249    pub youngs_modulus: f64,
250    /// Coefficient of thermal expansion (1/K).
251    pub cte: f64,
252    /// Yield strength at room temperature (Pa).
253    pub yield_strength: f64,
254    /// Peak temperature during processing (K).
255    pub peak_temperature: f64,
256    /// Ambient (room) temperature (K).
257    pub ambient_temp: f64,
258}
259impl ResidualStress {
260    /// Construct a [`ResidualStress`] model.
261    pub fn new(
262        youngs_modulus: f64,
263        cte: f64,
264        yield_strength: f64,
265        peak_temperature: f64,
266        ambient_temp: f64,
267    ) -> Self {
268        Self {
269            youngs_modulus,
270            cte,
271            yield_strength,
272            peak_temperature,
273            ambient_temp,
274        }
275    }
276    /// Estimated longitudinal residual stress (Pa) from the TGM.
277    ///
278    /// σ_res = min(E · α · ΔT, σ_yield) — capped at the yield strength.
279    pub fn longitudinal_stress(&self) -> f64 {
280        let delta_t = self.peak_temperature - self.ambient_temp;
281        let elastic = self.youngs_modulus * self.cte * delta_t;
282        elastic.min(self.yield_strength)
283    }
284    /// Stress ratio σ_res / σ_yield (dimensionless).
285    pub fn stress_ratio(&self) -> f64 {
286        self.longitudinal_stress() / self.yield_strength
287    }
288    /// Layer-by-layer accumulated stress assuming `n_layers` deposited.
289    ///
290    /// Uses a simplified accumulation model where each layer adds a fraction
291    /// `layer_fraction` (0–1) of the single-layer residual stress.
292    pub fn accumulated_stress(&self, n_layers: usize, layer_fraction: f64) -> f64 {
293        let sigma_per_layer = self.longitudinal_stress() * layer_fraction;
294        let total = sigma_per_layer * n_layers as f64;
295        total.min(self.yield_strength)
296    }
297    /// Boolean flag: is the residual stress likely to cause delamination?
298    ///
299    /// Uses a simplified criterion: stress > 0.8 × σ_yield.
300    pub fn delamination_risk(&self) -> bool {
301        self.longitudinal_stress() > 0.8_f64 * self.yield_strength
302    }
303}
304/// Functionally-graded material (FGM) deposition in multi-material AM.
305///
306/// Describes a composition transition between two constituent materials
307/// along the build direction using a power-law profile.
308#[derive(Debug, Clone)]
309pub struct MultiMaterialAM {
310    /// Young's modulus of material A (Pa).
311    pub e_a: f64,
312    /// Young's modulus of material B (Pa).
313    pub e_b: f64,
314    /// Density of material A (kg/m³).
315    pub rho_a: f64,
316    /// Density of material B (kg/m³).
317    pub rho_b: f64,
318    /// Thermal conductivity of material A (W/m·K).
319    pub k_a: f64,
320    /// Thermal conductivity of material B (W/m·K).
321    pub k_b: f64,
322    /// Power-law exponent *n* for the grading profile.
323    /// n=0 → pure A; n→∞ → pure B; n=1 → linear.
324    pub grading_exponent: f64,
325    /// Total build height over which grading occurs (m).
326    pub grading_height_m: f64,
327}
328impl MultiMaterialAM {
329    /// Construct a [`MultiMaterialAM`] graded transition.
330    #[allow(clippy::too_many_arguments)]
331    pub fn new(
332        e_a: f64,
333        e_b: f64,
334        rho_a: f64,
335        rho_b: f64,
336        k_a: f64,
337        k_b: f64,
338        grading_exponent: f64,
339        grading_height_m: f64,
340    ) -> Self {
341        Self {
342            e_a,
343            e_b,
344            rho_a,
345            rho_b,
346            k_a,
347            k_b,
348            grading_exponent,
349            grading_height_m,
350        }
351    }
352    /// Volume fraction of material B at height `z_m` (m from the bottom).
353    ///
354    /// `V_B(z) = (z / H)^n`
355    pub fn volume_fraction_b(&self, z_m: f64) -> f64 {
356        let xi = (z_m / self.grading_height_m.max(1.0e-15_f64)).clamp(0.0_f64, 1.0_f64);
357        xi.powf(self.grading_exponent)
358    }
359    /// Effective Young's modulus at height `z_m` using the rule of mixtures.
360    pub fn effective_modulus(&self, z_m: f64) -> f64 {
361        let vb = self.volume_fraction_b(z_m);
362        self.e_a * (1.0_f64 - vb) + self.e_b * vb
363    }
364    /// Effective density at height `z_m` (kg/m³).
365    pub fn effective_density(&self, z_m: f64) -> f64 {
366        let vb = self.volume_fraction_b(z_m);
367        self.rho_a * (1.0_f64 - vb) + self.rho_b * vb
368    }
369    /// Effective thermal conductivity at height `z_m` (W/m·K).
370    pub fn effective_conductivity(&self, z_m: f64) -> f64 {
371        let vb = self.volume_fraction_b(z_m);
372        self.k_a * (1.0_f64 - vb) + self.k_b * vb
373    }
374    /// Average (integrated) modulus over the full grading height.
375    pub fn average_modulus(&self) -> f64 {
376        self.e_a + (self.e_b - self.e_a) / (self.grading_exponent + 1.0_f64)
377    }
378}
379/// Goldak double-ellipsoid heat source model.
380///
381/// Reference: Goldak et al. (1984) *Met. Trans. B*, 15, 299–305.
382#[derive(Debug, Clone)]
383pub struct GoldakHeatSource {
384    /// Laser power (W).
385    pub power: f64,
386    /// Absorptivity (0–1).
387    pub absorptivity: f64,
388    /// Front ellipsoid semi-axis along scan direction (m).
389    pub a_front: f64,
390    /// Rear ellipsoid semi-axis along scan direction (m).
391    pub a_rear: f64,
392    /// Lateral semi-axis (m).
393    pub b: f64,
394    /// Depth semi-axis (m).
395    pub c: f64,
396    /// Fraction of heat deposited in the front ellipsoid.
397    pub f_front: f64,
398}
399impl GoldakHeatSource {
400    /// Construct a Goldak heat source with default fraction split (0.6 / 1.4).
401    pub fn new(power: f64, absorptivity: f64, a_front: f64, a_rear: f64, b: f64, c: f64) -> Self {
402        Self {
403            power,
404            absorptivity,
405            a_front,
406            a_rear,
407            b,
408            c,
409            f_front: 0.6_f64,
410        }
411    }
412    fn f_rear(&self) -> f64 {
413        2.0_f64 - self.f_front
414    }
415    /// Volumetric heat flux (W/m³) at position `(x, y, z)` relative to the
416    /// instantaneous heat source centre.  Positive *x* points in the scan
417    /// direction, *z* points downward into the substrate.
418    pub fn heat_flux(&self, x: f64, y: f64, z: f64) -> f64 {
419        let q = self.absorptivity * self.power;
420        let b2 = self.b * self.b;
421        let c2 = self.c * self.c;
422        let common = 6.0_f64 * f64::sqrt(3.0_f64) * q;
423        if x >= 0.0_f64 {
424            let a2 = self.a_front * self.a_front;
425            let coeff =
426                common * self.f_front / (PI * f64::sqrt(PI) * self.a_front * self.b * self.c);
427            coeff * (-3.0_f64 * x * x / a2 - 3.0_f64 * y * y / b2 - 3.0_f64 * z * z / c2).exp()
428        } else {
429            let a2 = self.a_rear * self.a_rear;
430            let coeff =
431                common * self.f_rear() / (PI * f64::sqrt(PI) * self.a_rear * self.b * self.c);
432            coeff * (-3.0_f64 * x * x / a2 - 3.0_f64 * y * y / b2 - 3.0_f64 * z * z / c2).exp()
433        }
434    }
435    /// Peak heat flux at the source centre (W/m³).
436    pub fn peak_heat_flux(&self) -> f64 {
437        self.heat_flux(0.0_f64, 0.0_f64, 0.0_f64)
438    }
439}
440/// Laser scanning strategy for each layer.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum ScanStrategy {
443    /// Continuous raster scan lines parallel to the X-axis.
444    Raster,
445    /// Alternating 90° rotation between consecutive layers.
446    Alternating90,
447    /// 67° rotation between consecutive layers (reduces texture).
448    Rotating67,
449    /// Concentric inward spiral islands.
450    Islands,
451    /// Chessboard (checkerboard) pattern.
452    Chessboard,
453}
454/// Anisotropic mechanical properties arising from the columnar-grain
455/// microstructure of LPBF or DED parts.
456#[derive(Debug, Clone)]
457pub struct AnisotropicAM {
458    /// Build direction.
459    pub build_direction: BuildDirection,
460    /// Young's modulus along the build direction (Pa).
461    pub e_parallel: f64,
462    /// Young's modulus transverse to the build direction (Pa).
463    pub e_transverse: f64,
464    /// Yield strength along the build direction (Pa).
465    pub sigma_y_parallel: f64,
466    /// Yield strength transverse to the build direction (Pa).
467    pub sigma_y_transverse: f64,
468    /// Texture coefficient (0 = random, 1 = perfectly aligned <001>).
469    pub texture_coefficient: f64,
470    /// Average columnar grain aspect ratio (length / width).
471    pub grain_aspect_ratio: f64,
472}
473impl AnisotropicAM {
474    /// Create an [`AnisotropicAM`] model.
475    pub fn new(
476        build_direction: BuildDirection,
477        e_parallel: f64,
478        e_transverse: f64,
479        sigma_y_parallel: f64,
480        sigma_y_transverse: f64,
481        texture_coefficient: f64,
482        grain_aspect_ratio: f64,
483    ) -> Self {
484        Self {
485            build_direction,
486            e_parallel,
487            e_transverse,
488            sigma_y_parallel,
489            sigma_y_transverse,
490            texture_coefficient,
491            grain_aspect_ratio,
492        }
493    }
494    /// Anisotropy index: (E_transverse − E_parallel) / E_parallel.
495    pub fn anisotropy_index(&self) -> f64 {
496        (self.e_transverse - self.e_parallel) / self.e_parallel
497    }
498    /// Effective modulus for a loading angle `theta` (radians) from the build
499    /// direction, using a simple cosine interpolation.
500    pub fn effective_modulus(&self, theta: f64) -> f64 {
501        let ct = theta.cos();
502        let st = theta.sin();
503        1.0_f64 / (ct * ct / self.e_parallel + st * st / self.e_transverse)
504    }
505    /// Effective yield strength for loading angle `theta` (radians).
506    pub fn effective_yield_strength(&self, theta: f64) -> f64 {
507        let ct = theta.cos().abs();
508        let st = theta.sin().abs();
509        self.sigma_y_parallel * ct + self.sigma_y_transverse * st
510    }
511}
512/// Directed Energy Deposition (DED) process model.
513///
514/// Captures clad geometry, dilution ratio and conditions favouring
515/// epitaxial grain growth (substrate crystallographic continuity).
516#[derive(Debug, Clone)]
517pub struct DirectedEnergyDeposition {
518    /// Laser power (W).
519    pub laser_power: f64,
520    /// Powder feed rate (kg/s).
521    pub powder_feed_rate: f64,
522    /// Scan speed (m/s).
523    pub scan_speed: f64,
524    /// Laser spot radius on the substrate (m).
525    pub spot_radius: f64,
526    /// Powder catchment efficiency (0–1).
527    pub catchment_efficiency: f64,
528    /// Substrate thermal conductivity (W/m·K).
529    pub substrate_conductivity: f64,
530    /// Melt pool absorptivity for the powder stream (0–1).
531    pub absorptivity: f64,
532}
533impl DirectedEnergyDeposition {
534    /// Construct a [`DirectedEnergyDeposition`] model.
535    pub fn new(
536        laser_power: f64,
537        powder_feed_rate: f64,
538        scan_speed: f64,
539        spot_radius: f64,
540        catchment_efficiency: f64,
541        substrate_conductivity: f64,
542        absorptivity: f64,
543    ) -> Self {
544        Self {
545            laser_power,
546            powder_feed_rate,
547            scan_speed,
548            spot_radius,
549            catchment_efficiency,
550            substrate_conductivity,
551            absorptivity,
552        }
553    }
554    /// Effective deposited power (W) after absorptivity losses.
555    pub fn effective_power(&self) -> f64 {
556        self.absorptivity * self.laser_power
557    }
558    /// Clad bead height (m) from a mass-balance estimate.
559    ///
560    /// `h ≈ ṁ_dep / (ρ_deposit × v × w)`
561    /// Using a simplified density (7800 kg/m³ steel) and width ≈ 2·r.
562    pub fn clad_height_m(&self, deposit_density: f64) -> f64 {
563        let width = 2.0_f64 * self.spot_radius;
564        let mass_flow = self.powder_feed_rate * self.catchment_efficiency;
565        let volume_per_m = mass_flow / (deposit_density * self.scan_speed * width);
566        volume_per_m.max(0.0_f64)
567    }
568    /// Dilution ratio D = (substrate melted volume) / (clad + substrate volume).
569    ///
570    /// Estimated from the energy balance: higher power → deeper penetration.
571    pub fn dilution_ratio(&self) -> f64 {
572        let e_s = self.effective_power() / (self.scan_speed * self.spot_radius).max(1.0e-15_f64);
573        let e_ref = 5.0e6_f64;
574        1.0_f64 - (-e_s / e_ref).exp()
575    }
576    /// True if conditions favour epitaxial grain growth (columnar solidification).
577    ///
578    /// Epitaxial growth occurs when the temperature gradient G is high and
579    /// the solidification rate R is low (G/R > threshold).
580    pub fn epitaxial_growth_likely(&self) -> bool {
581        let g_over_r = self.effective_power()
582            / (self.scan_speed.powi(2) * self.spot_radius * self.substrate_conductivity)
583                .max(1.0e-30_f64);
584        g_over_r > 1.0e6_f64
585    }
586    /// Powder utilisation efficiency (0–1): fraction of fed powder that is
587    /// incorporated into the deposit.
588    pub fn utilisation_efficiency(&self) -> f64 {
589        self.catchment_efficiency.clamp(0.0_f64, 1.0_f64)
590    }
591}
592/// Thermal history parameters for a melt-track or layer.
593#[derive(Debug, Clone)]
594pub struct ThermalHistory {
595    /// Melt pool geometry.
596    pub melt_pool: MeltPoolGeometry,
597    /// Peak temperature reached in the melt pool (K).
598    pub peak_temperature: f64,
599    /// Solidification cooling rate (K/s).
600    pub cooling_rate: f64,
601    /// Thermal gradient at the solidification front (K/m).
602    pub thermal_gradient: f64,
603    /// Width of the heat-affected zone (HAZ) outside the melt pool (m).
604    pub haz_width: f64,
605    /// Whether recrystallisation is expected (temperature exceeded 0.5 × T_melt).
606    pub recrystallised: bool,
607}
608impl ThermalHistory {
609    /// Construct a [`ThermalHistory`] from process and material parameters using
610    /// simplified analytical estimates.
611    ///
612    /// # Parameters
613    /// - `layer`: layer model (power, speed, …)
614    /// - `absorptivity`: laser absorptivity of the powder bed (0–1)
615    /// - `thermal_conductivity`: bulk thermal conductivity (W/m·K)
616    /// - `density`: material density (kg/m³)
617    /// - `specific_heat`: specific heat capacity (J/kg·K)
618    /// - `melting_point`: solidus temperature (K)
619    /// - `ambient_temp`: substrate / build plate temperature (K)
620    pub fn from_process(
621        layer: &LayerModel,
622        absorptivity: f64,
623        thermal_conductivity: f64,
624        density: f64,
625        specific_heat: f64,
626        melting_point: f64,
627        ambient_temp: f64,
628    ) -> Self {
629        let effective_power = absorptivity * layer.laser_power;
630        let _diffusivity = thermal_conductivity / (density * specific_heat);
631        let r = layer.spot_radius.max(1.0e-6_f64);
632        let peak_temperature =
633            ambient_temp + effective_power / (2.0_f64 * PI * thermal_conductivity * r);
634        let thermal_gradient = (peak_temperature - ambient_temp) / r;
635        let cooling_rate = layer.scan_speed * thermal_gradient;
636        let q_eff = effective_power;
637        let half_length = q_eff
638            / (2.0_f64 * PI * thermal_conductivity * (peak_temperature - ambient_temp))
639                .max(1.0e-20_f64);
640        let half_width = half_length * 0.6_f64;
641        let depth = half_length * 0.4_f64;
642        let haz_width = half_width * 1.5_f64;
643        let recrystallised = peak_temperature > 0.5_f64 * melting_point;
644        ThermalHistory {
645            melt_pool: MeltPoolGeometry {
646                half_length,
647                half_width,
648                depth,
649            },
650            peak_temperature,
651            cooling_rate,
652            thermal_gradient,
653            haz_width,
654            recrystallised,
655        }
656    }
657    /// Solidification rate (m/s) — ratio of scan speed projected onto the
658    /// liquid/solid interface.  Simplified: G_s = cooling_rate / thermal_gradient.
659    pub fn solidification_rate(&self) -> f64 {
660        if self.thermal_gradient.abs() < 1.0e-12_f64 {
661            0.0_f64
662        } else {
663            self.cooling_rate / self.thermal_gradient
664        }
665    }
666    /// Primary dendrite arm spacing (PDAS) in metres using the Hunt–Kurz
667    /// power-law: λ₁ = A · (G · R)^(-0.25).
668    ///
669    /// Coefficient *A* ≈ 80 µm for typical Ti-6Al-4V (SI units).
670    pub fn primary_dendrite_arm_spacing(&self) -> f64 {
671        let a = 80.0e-6_f64;
672        let gr = self.thermal_gradient * self.solidification_rate();
673        if gr < 1.0e-20_f64 {
674            a
675        } else {
676            a * gr.powf(-0.25_f64)
677        }
678    }
679}
680/// Binder jetting process model: powder spreading, binder saturation,
681/// and sintering-induced shrinkage.
682#[derive(Debug, Clone)]
683pub struct BinderJetting {
684    /// Nominal powder layer thickness (m).
685    pub layer_thickness_m: f64,
686    /// Powder bed packing density (0–1).
687    pub packing_density: f64,
688    /// Binder saturation ratio (volume of binder / volume of void).
689    pub binder_saturation: f64,
690    /// Powder particle mean diameter (m).
691    pub particle_diameter_m: f64,
692    /// Sintering shrinkage fraction (linear, 0–1).
693    /// Typical: 0.15–0.22 for metals.
694    pub sintering_shrinkage: f64,
695    /// Green-part relative density (0–1) — after binding, before sinter.
696    pub green_density: f64,
697}
698impl BinderJetting {
699    /// Construct a [`BinderJetting`] model.
700    pub fn new(
701        layer_thickness_m: f64,
702        packing_density: f64,
703        binder_saturation: f64,
704        particle_diameter_m: f64,
705        sintering_shrinkage: f64,
706    ) -> Self {
707        let green_density = packing_density * binder_saturation.clamp(0.0_f64, 1.0_f64);
708        Self {
709            layer_thickness_m,
710            packing_density: packing_density.clamp(0.0_f64, 1.0_f64),
711            binder_saturation: binder_saturation.clamp(0.0_f64, 1.0_f64),
712            particle_diameter_m,
713            sintering_shrinkage: sintering_shrinkage.clamp(0.0_f64, 0.5_f64),
714            green_density,
715        }
716    }
717    /// Void fraction in the powder bed (1 − packing_density).
718    pub fn void_fraction(&self) -> f64 {
719        1.0_f64 - self.packing_density
720    }
721    /// Binder volume fraction relative to total layer volume.
722    pub fn binder_volume_fraction(&self) -> f64 {
723        self.void_fraction() * self.binder_saturation
724    }
725    /// Final linear dimension of a part after sintering.
726    ///
727    /// `L_final = L_green × (1 − sintering_shrinkage)`
728    pub fn sintered_dimension(&self, green_dimension_m: f64) -> f64 {
729        green_dimension_m * (1.0_f64 - self.sintering_shrinkage)
730    }
731    /// Volumetric shrinkage fraction after sintering.
732    ///
733    /// V_shrink / V_green = 1 − (1 − s)³
734    pub fn volumetric_shrinkage(&self) -> f64 {
735        let s = self.sintering_shrinkage;
736        1.0_f64 - (1.0_f64 - s).powi(3)
737    }
738    /// Spreading speed limit estimate (m/s) based on Carman–Kozeny drag.
739    ///
740    /// Higher packing density or smaller particles lower the feasible
741    /// recoater speed.
742    pub fn max_spreading_speed_m_s(&self) -> f64 {
743        let k_kozeny = 5.0_f64;
744        let porosity = self.void_fraction().max(0.01_f64);
745        let d = self.particle_diameter_m.max(1.0e-9_f64);
746        porosity.powi(3) / (k_kozeny * (1.0_f64 - porosity).powi(2) * d)
747    }
748}
749/// Steady-state Rosenthal temperature field for a moving point heat source.
750///
751/// Reference: Rosenthal (1946) *Trans. ASME*, 68, 849–866.
752#[derive(Debug, Clone)]
753pub struct RosenthalSolution {
754    /// Laser power (W).
755    pub power: f64,
756    /// Absorptivity (0–1).
757    pub absorptivity: f64,
758    /// Scan speed (m/s).
759    pub scan_speed: f64,
760    /// Thermal conductivity (W/m·K).
761    pub thermal_conductivity: f64,
762    /// Thermal diffusivity (m²/s).
763    pub diffusivity: f64,
764    /// Ambient / preheat temperature (K).
765    pub ambient_temp: f64,
766}
767impl RosenthalSolution {
768    /// Construct a [`RosenthalSolution`].
769    pub fn new(
770        power: f64,
771        absorptivity: f64,
772        scan_speed: f64,
773        thermal_conductivity: f64,
774        diffusivity: f64,
775        ambient_temp: f64,
776    ) -> Self {
777        Self {
778            power,
779            absorptivity,
780            scan_speed,
781            thermal_conductivity,
782            diffusivity,
783            ambient_temp,
784        }
785    }
786    /// Temperature (K) at position `(xi, y, z)` in the moving heat-source
787    /// frame.  `xi = x − v·t` is the distance behind the laser spot.
788    ///
789    /// Returns `f64::INFINITY` at the exact source centre.
790    pub fn temperature(&self, xi: f64, y: f64, z: f64) -> f64 {
791        let r = (xi * xi + y * y + z * z).sqrt();
792        if r < 1.0e-15_f64 {
793            return f64::INFINITY;
794        }
795        let q = self.absorptivity * self.power;
796        let v = self.scan_speed;
797        let alpha = self.diffusivity;
798        let k = self.thermal_conductivity;
799        let exponent = -v / (2.0_f64 * alpha) * (r + xi);
800        self.ambient_temp + q / (2.0_f64 * PI * k * r) * exponent.exp()
801    }
802    /// Melt-pool width (m) at the surface (`z = 0`) given the melting point
803    /// temperature `t_melt` (K).  Uses a bisection search.
804    pub fn melt_pool_width(&self, t_melt: f64) -> f64 {
805        let mut lo = 1.0e-9_f64;
806        let mut hi = 1.0e-2_f64;
807        for _ in 0..60 {
808            let mid = 0.5_f64 * (lo + hi);
809            let t = self.temperature(0.0_f64, mid, 0.0_f64);
810            if t > t_melt {
811                lo = mid;
812            } else {
813                hi = mid;
814            }
815        }
816        2.0_f64 * 0.5_f64 * (lo + hi)
817    }
818}
819/// Principal build direction for an AM part.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum BuildDirection {
822    /// Positive Z direction (standard upward build).
823    PlusZ,
824    /// Build tilted 45° from the Z axis toward X.
825    Tilted45X,
826    /// Horizontal build (powder-bed binder jetting or FDM on side).
827    Horizontal,
828    /// Custom direction stored as a unit vector index.
829    Custom,
830}
831/// Process-window model mapping energy density to part quality metrics.
832#[derive(Debug, Clone)]
833pub struct ProcessWindow {
834    /// Material density of the fully dense material (kg/m³).
835    pub theoretical_density: f64,
836    /// Lower bound energy density for good densification (J/m³).
837    pub e_low: f64,
838    /// Upper bound energy density before keyhole formation (J/m³).
839    pub e_high: f64,
840    /// Vickers hardness at theoretical density (HV).
841    pub hardness_full_dense: f64,
842    /// Fitted Hall–Petch coefficient (HV·m^0.5).
843    pub hall_petch_k: f64,
844    /// Grain size at optimal energy density (m).
845    pub grain_size_opt: f64,
846}
847impl ProcessWindow {
848    /// Construct a [`ProcessWindow`].
849    pub fn new(
850        theoretical_density: f64,
851        e_low: f64,
852        e_high: f64,
853        hardness_full_dense: f64,
854        hall_petch_k: f64,
855        grain_size_opt: f64,
856    ) -> Self {
857        Self {
858            theoretical_density,
859            e_low,
860            e_high,
861            hardness_full_dense,
862            hall_petch_k,
863            grain_size_opt,
864        }
865    }
866    /// Predicted relative density (0–1) as a function of `energy_density` (J/m³).
867    ///
868    /// Uses a sigmoidal model: `ρ_rel = 1 / (1 + exp(−k · (E − E_mid)))`.
869    pub fn relative_density(&self, energy_density: f64) -> f64 {
870        let e_mid = 0.5_f64 * (self.e_low + self.e_high);
871        let k = 5.0_f64 / (self.e_high - self.e_low).max(1.0_f64);
872        1.0_f64 / (1.0_f64 + (-k * (energy_density - e_mid)).exp())
873    }
874    /// Predicted Vickers hardness at a given `energy_density` (J/m³).
875    ///
876    /// Combines density-based degradation with a Hall–Petch grain-size term.
877    pub fn hardness_prediction(&self, energy_density: f64, grain_size: f64) -> f64 {
878        let rel = self.relative_density(energy_density);
879        let hp = self.hall_petch_k / grain_size.sqrt();
880        rel * (self.hardness_full_dense + hp - self.hall_petch_k / self.grain_size_opt.sqrt())
881    }
882    /// True if `energy_density` lies within the acceptable process window.
883    pub fn is_in_window(&self, energy_density: f64) -> bool {
884        energy_density >= self.e_low && energy_density <= self.e_high
885    }
886    /// Window width in J/m³.
887    pub fn window_width(&self) -> f64 {
888        (self.e_high - self.e_low).max(0.0_f64)
889    }
890}
891/// Pre-deformation (negative distortion) to compensate for warpage in LPBF.
892///
893/// The method scales every point's displacement by a compensation factor so
894/// that after springback the part converges to the nominal geometry.
895#[derive(Debug, Clone)]
896pub struct DistortionCompensation {
897    /// Scaling factor applied to predicted displacements (typically −1 to −2).
898    pub scale_factor: f64,
899    /// Maximum allowable pre-deformation magnitude (m).
900    pub max_deformation_m: f64,
901    /// Convergence tolerance (m) — iterations stop when RMS update < tol.
902    pub tolerance_m: f64,
903    /// Number of compensation iterations performed.
904    pub iteration_count: usize,
905}
906impl DistortionCompensation {
907    /// Construct a [`DistortionCompensation`] model.
908    ///
909    /// `scale_factor` < 0 inverts the predicted distortion field.
910    pub fn new(scale_factor: f64, max_deformation_m: f64, tolerance_m: f64) -> Self {
911        Self {
912            scale_factor,
913            max_deformation_m,
914            tolerance_m,
915            iteration_count: 0,
916        }
917    }
918    /// Apply compensation to a list of nodal displacement predictions.
919    ///
920    /// Each element of `displacements_m` is the predicted warpage for one
921    /// node.  The function returns the compensated (pre-deformed) geometry
922    /// offsets.
923    pub fn apply(&self, displacements_m: &[f64]) -> Vec<f64> {
924        displacements_m
925            .iter()
926            .map(|d| {
927                let comp = self.scale_factor * d;
928                comp.clamp(-self.max_deformation_m, self.max_deformation_m)
929            })
930            .collect()
931    }
932    /// Iterative loop: repeatedly apply compensation and check convergence.
933    ///
934    /// Returns the final compensated displacement field after at most
935    /// `max_iter` iterations.
936    pub fn iterate(&mut self, initial_displacements: &[f64], max_iter: usize) -> Vec<f64> {
937        let mut current = initial_displacements.to_vec();
938        self.iteration_count = 0;
939        for _i in 0..max_iter {
940            let next = self.apply(&current);
941            let rms: f64 = next
942                .iter()
943                .zip(current.iter())
944                .map(|(a, b)| (a - b).powi(2))
945                .sum::<f64>()
946                / (next.len().max(1) as f64);
947            current = next;
948            self.iteration_count += 1;
949            if rms.sqrt() < self.tolerance_m {
950                break;
951            }
952        }
953        current
954    }
955    /// RMS magnitude of a displacement field (m).
956    pub fn rms_displacement(displacements_m: &[f64]) -> f64 {
957        if displacements_m.is_empty() {
958            return 0.0_f64;
959        }
960        let sum_sq: f64 = displacements_m.iter().map(|d| d * d).sum();
961        (sum_sq / displacements_m.len() as f64).sqrt()
962    }
963}
964/// Scan-speed vs laser-power optimiser for minimal porosity and good surface.
965///
966/// Sweeps a discrete grid of (power, speed) pairs and scores each point
967/// using a combined porosity-and-surface-roughness cost function.
968#[derive(Debug, Clone)]
969pub struct ProcessWindowOptimizer {
970    /// Minimum laser power to evaluate (W).
971    pub power_min: f64,
972    /// Maximum laser power to evaluate (W).
973    pub power_max: f64,
974    /// Minimum scan speed to evaluate (m/s).
975    pub speed_min: f64,
976    /// Maximum scan speed to evaluate (m/s).
977    pub speed_max: f64,
978    /// Layer thickness (m) — fixed during optimisation.
979    pub layer_thickness: f64,
980    /// Hatch spacing (m) — fixed during optimisation.
981    pub hatch_spacing: f64,
982    /// Lower energy-density threshold for dense parts (J/m³).
983    pub e_low: f64,
984    /// Upper energy-density threshold before keyhole (J/m³).
985    pub e_high: f64,
986}
987impl ProcessWindowOptimizer {
988    /// Construct a [`ProcessWindowOptimizer`].
989    #[allow(clippy::too_many_arguments)]
990    pub fn new(
991        power_min: f64,
992        power_max: f64,
993        speed_min: f64,
994        speed_max: f64,
995        layer_thickness: f64,
996        hatch_spacing: f64,
997        e_low: f64,
998        e_high: f64,
999    ) -> Self {
1000        Self {
1001            power_min,
1002            power_max,
1003            speed_min,
1004            speed_max,
1005            layer_thickness,
1006            hatch_spacing,
1007            e_low,
1008            e_high,
1009        }
1010    }
1011    /// Compute volumetric energy density for a (power, speed) pair (J/m³).
1012    pub fn energy_density(&self, power: f64, speed: f64) -> f64 {
1013        power / (speed * self.hatch_spacing * self.layer_thickness).max(1.0e-30_f64)
1014    }
1015    /// Porosity score for a given energy density (lower is better, 0 = optimal).
1016    ///
1017    /// Uses a quadratic penalty outside the \[e_low, e_high\] window.
1018    pub fn porosity_score(&self, ev: f64) -> f64 {
1019        if ev < self.e_low {
1020            ((self.e_low - ev) / self.e_low).powi(2)
1021        } else if ev > self.e_high {
1022            ((ev - self.e_high) / self.e_high).powi(2)
1023        } else {
1024            0.0_f64
1025        }
1026    }
1027    /// Surface roughness score for a given scan speed (lower is better).
1028    ///
1029    /// Higher scan speeds produce rougher surfaces (balling) above a threshold.
1030    pub fn roughness_score(&self, speed: f64) -> f64 {
1031        let v_ref = 0.8_f64 * self.speed_max;
1032        if speed > v_ref {
1033            ((speed - v_ref) / (self.speed_max - v_ref + 1.0e-30_f64)).powi(2)
1034        } else {
1035            0.0_f64
1036        }
1037    }
1038    /// Combined cost for a (power, speed) pair.
1039    pub fn cost(&self, power: f64, speed: f64) -> f64 {
1040        let ev = self.energy_density(power, speed);
1041        self.porosity_score(ev) + 0.5_f64 * self.roughness_score(speed)
1042    }
1043    /// Sweep `n_points × n_points` grid and return the (power, speed) with the
1044    /// lowest combined cost.
1045    pub fn optimise(&self, n_points: usize) -> (f64, f64) {
1046        let n = n_points.max(2);
1047        let mut best_cost = f64::INFINITY;
1048        let mut best_power = self.power_min;
1049        let mut best_speed = self.speed_min;
1050        for ip in 0..n {
1051            let p = self.power_min + ip as f64 * (self.power_max - self.power_min) / (n - 1) as f64;
1052            for iv in 0..n {
1053                let v =
1054                    self.speed_min + iv as f64 * (self.speed_max - self.speed_min) / (n - 1) as f64;
1055                let c = self.cost(p, v);
1056                if c < best_cost {
1057                    best_cost = c;
1058                    best_power = p;
1059                    best_speed = v;
1060                }
1061            }
1062        }
1063        (best_power, best_speed)
1064    }
1065}