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(¤t);
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}