Skip to main content

oxiphysics_core/
topology_optimization.rs

1#![allow(clippy::needless_range_loop, clippy::ptr_arg)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Topology optimization methods including SIMP, level-set, and manufacturing filters.
6//!
7//! This module provides solid-topology optimization solvers for structural
8//! compliance minimization under volume constraints, with density filtering,
9//! sensitivity filtering, and Heaviside projection.
10
11#![allow(dead_code)]
12
13use std::f64::consts::PI;
14
15// ---------------------------------------------------------------------------
16// Helper types
17// ---------------------------------------------------------------------------
18
19/// 2D grid size descriptor.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct GridSize {
22    /// Number of elements in the x direction.
23    pub nx: usize,
24    /// Number of elements in the y direction.
25    pub ny: usize,
26}
27
28impl GridSize {
29    /// Creates a new `GridSize`.
30    pub fn new(nx: usize, ny: usize) -> Self {
31        Self { nx, ny }
32    }
33
34    /// Returns total number of elements.
35    pub fn n_elem(&self) -> usize {
36        self.nx * self.ny
37    }
38
39    /// Returns (row, col) for a flat element index.
40    pub fn row_col(&self, idx: usize) -> (usize, usize) {
41        (idx / self.nx, idx % self.nx)
42    }
43}
44
45/// Material parameters for structural problems.
46#[derive(Debug, Clone, Copy)]
47pub struct MaterialParams {
48    /// Young's modulus of the solid material.
49    pub e_solid: f64,
50    /// Young's modulus of the void material (usually small positive).
51    pub e_void: f64,
52    /// Poisson's ratio.
53    pub nu: f64,
54}
55
56impl MaterialParams {
57    /// Creates new material parameters.
58    pub fn new(e_solid: f64, e_void: f64, nu: f64) -> Self {
59        Self {
60            e_solid,
61            e_void,
62            nu,
63        }
64    }
65}
66
67impl Default for MaterialParams {
68    fn default() -> Self {
69        Self {
70            e_solid: 1.0,
71            e_void: 1e-9,
72            nu: 0.3,
73        }
74    }
75}
76
77// ---------------------------------------------------------------------------
78// SIMP — Solid Isotropic Material with Penalization
79// ---------------------------------------------------------------------------
80
81/// Solid Isotropic Material with Penalization (SIMP) model.
82///
83/// SIMP interpolates material stiffness as `E(ρ) = E_void + ρ^p * (E_solid - E_void)`.
84/// The penalization parameter `p` discourages intermediate densities.
85#[derive(Debug, Clone)]
86pub struct SiMP {
87    /// Penalization exponent.
88    pub penalty: f64,
89    /// Material parameters.
90    pub material: MaterialParams,
91    /// Volume fraction target.
92    pub volume_fraction: f64,
93    /// Filter radius for density filtering.
94    pub filter_radius: f64,
95    /// Grid size.
96    pub grid: GridSize,
97    /// Current density field (length = grid.n_elem()).
98    pub density: Vec<f64>,
99}
100
101impl SiMP {
102    /// Creates a new SIMP model with uniform initial density equal to volume_fraction.
103    pub fn new(
104        grid: GridSize,
105        volume_fraction: f64,
106        penalty: f64,
107        filter_radius: f64,
108        material: MaterialParams,
109    ) -> Self {
110        let n = grid.n_elem();
111        let density = vec![volume_fraction; n];
112        Self {
113            penalty,
114            material,
115            volume_fraction,
116            filter_radius,
117            grid,
118            density,
119        }
120    }
121
122    /// Computes the interpolated Young's modulus for a given density.
123    ///
124    /// `E(ρ) = E_void + ρ^p * (E_solid - E_void)`
125    pub fn interpolated_stiffness(&self, rho: f64) -> f64 {
126        let rho_clamped = rho.clamp(0.0, 1.0);
127        self.material.e_void
128            + rho_clamped.powf(self.penalty) * (self.material.e_solid - self.material.e_void)
129    }
130
131    /// Computes the derivative of interpolated stiffness with respect to density.
132    ///
133    /// `dE/dρ = p * ρ^(p-1) * (E_solid - E_void)`
134    pub fn stiffness_sensitivity(&self, rho: f64) -> f64 {
135        let rho_clamped = rho.clamp(1e-12, 1.0);
136        self.penalty
137            * rho_clamped.powf(self.penalty - 1.0)
138            * (self.material.e_solid - self.material.e_void)
139    }
140
141    /// Returns vector of interpolated stiffnesses for current density field.
142    pub fn stiffness_field(&self) -> Vec<f64> {
143        self.density
144            .iter()
145            .map(|&r| self.interpolated_stiffness(r))
146            .collect()
147    }
148
149    /// Returns sensitivities `dE/dρ` for current density field.
150    pub fn sensitivity_field(&self) -> Vec<f64> {
151        self.density
152            .iter()
153            .map(|&r| self.stiffness_sensitivity(r))
154            .collect()
155    }
156
157    /// Computes element-wise compliance sensitivity given element displacements.
158    ///
159    /// `dc/dρ_e = -p * ρ_e^(p-1) * (E_solid - E_void) * u_e^T * K_e_unit * u_e`
160    pub fn compliance_sensitivity(&self, element_strain_energy: &[f64]) -> Vec<f64> {
161        self.density
162            .iter()
163            .zip(element_strain_energy.iter())
164            .map(|(&rho, &se)| {
165                let rho_c = rho.clamp(1e-12, 1.0);
166                -self.penalty
167                    * rho_c.powf(self.penalty - 1.0)
168                    * (self.material.e_solid - self.material.e_void)
169                    * se
170            })
171            .collect()
172    }
173
174    /// Computes current volume fraction.
175    pub fn current_volume(&self) -> f64 {
176        let sum: f64 = self.density.iter().sum();
177        sum / self.density.len() as f64
178    }
179
180    /// Returns whether the density field is valid (all values in \[0,1\]).
181    pub fn is_valid(&self) -> bool {
182        self.density.iter().all(|&r| (0.0..=1.0).contains(&r))
183    }
184}
185
186// ---------------------------------------------------------------------------
187// FilteringMethods
188// ---------------------------------------------------------------------------
189
190/// Provides density filtering and sensitivity filtering for topology optimization.
191///
192/// Filtering suppresses checkerboard patterns and mesh dependence in SIMP.
193#[derive(Debug, Clone)]
194pub struct FilteringMethods {
195    /// Filter radius.
196    pub radius: f64,
197    /// Grid size.
198    pub grid: GridSize,
199    /// Precomputed neighbor weights for each element.
200    neighbor_weights: Vec<Vec<(usize, f64)>>,
201}
202
203impl FilteringMethods {
204    /// Creates a new `FilteringMethods` and precomputes neighbor lists.
205    pub fn new(radius: f64, grid: GridSize) -> Self {
206        let neighbor_weights = Self::compute_neighbors(radius, grid);
207        Self {
208            radius,
209            grid,
210            neighbor_weights,
211        }
212    }
213
214    fn compute_neighbors(radius: f64, grid: GridSize) -> Vec<Vec<(usize, f64)>> {
215        let n = grid.n_elem();
216        let mut result = vec![Vec::new(); n];
217        for i in 0..n {
218            let (ri, ci) = grid.row_col(i);
219            for j in 0..n {
220                let (rj, cj) = grid.row_col(j);
221                let dist =
222                    ((ri as f64 - rj as f64).powi(2) + (ci as f64 - cj as f64).powi(2)).sqrt();
223                if dist < radius {
224                    result[i].push((j, radius - dist));
225                }
226            }
227        }
228        result
229    }
230
231    /// Applies sensitivity filter to sensitivity field.
232    ///
233    /// `dc_filtered_e = (1/max(γ, ρ_e * Σ H_f)) * Σ H_f * ρ_f * dc_f`
234    pub fn sensitivity_filter(&self, density: &[f64], sensitivity: &[f64]) -> Vec<f64> {
235        let n = self.grid.n_elem();
236        let mut filtered = vec![0.0; n];
237        for e in 0..n {
238            let mut num = 0.0f64;
239            let mut denom = 0.0f64;
240            for &(f, h) in &self.neighbor_weights[e] {
241                num += h * density[f] * sensitivity[f];
242                denom += h * density[f];
243            }
244            let rho_e = density[e];
245            let denom_safe = denom.max(1e-12 * rho_e.max(1e-12));
246            filtered[e] = num / denom_safe;
247        }
248        filtered
249    }
250
251    /// Applies density filter (linear weighted average).
252    ///
253    /// `ρ_filtered_e = Σ H_f * ρ_f / Σ H_f`
254    pub fn density_filter(&self, density: &[f64]) -> Vec<f64> {
255        let n = self.grid.n_elem();
256        let mut filtered = vec![0.0; n];
257        for e in 0..n {
258            let mut num = 0.0f64;
259            let mut denom = 0.0f64;
260            for &(f, h) in &self.neighbor_weights[e] {
261                num += h * density[f];
262                denom += h;
263            }
264            filtered[e] = if denom > 0.0 { num / denom } else { density[e] };
265        }
266        filtered
267    }
268
269    /// Applies Heaviside projection filter.
270    ///
271    /// `ρ̄_e = (tanh(β*η) + tanh(β*(ρ_e - η))) / (tanh(β*η) + tanh(β*(1-η)))`
272    pub fn heaviside_projection(&self, density: &[f64], beta: f64, eta: f64) -> Vec<f64> {
273        let denom = (beta * eta).tanh() + (beta * (1.0 - eta)).tanh();
274        density
275            .iter()
276            .map(|&rho| ((beta * eta).tanh() + (beta * (rho - eta)).tanh()) / denom.max(1e-12))
277            .collect()
278    }
279
280    /// Computes derivative of Heaviside projection with respect to filtered density.
281    pub fn heaviside_sensitivity(&self, density: &[f64], beta: f64, eta: f64) -> Vec<f64> {
282        let denom = (beta * eta).tanh() + (beta * (1.0 - eta)).tanh();
283        density
284            .iter()
285            .map(|&rho| beta * (1.0 - (beta * (rho - eta)).tanh().powi(2)) / denom.max(1e-12))
286            .collect()
287    }
288
289    /// Propagates sensitivity through density filter (chain rule).
290    pub fn filter_sensitivity_chain(&self, sensitivity: &[f64], density: &[f64]) -> Vec<f64> {
291        // Adjoint of density filter
292        let n = self.grid.n_elem();
293        let mut out = vec![0.0; n];
294        for e in 0..n {
295            let denom: f64 = self.neighbor_weights[e]
296                .iter()
297                .map(|&(_, h)| h)
298                .sum::<f64>()
299                .max(1e-12);
300            for &(f, h) in &self.neighbor_weights[e] {
301                out[f] += sensitivity[e] * h / denom;
302            }
303        }
304        let _ = density; // density may be used for weighted variants
305        out
306    }
307}
308
309// ---------------------------------------------------------------------------
310// TopOptSolver — optimality criteria
311// ---------------------------------------------------------------------------
312
313/// Topology optimization solver using the Optimality Criteria (OC) method.
314///
315/// The OC update rule adjusts densities to satisfy KKT conditions for
316/// compliance minimization subject to a volume constraint.
317#[derive(Debug, Clone)]
318pub struct TopOptSolver {
319    /// SIMP model.
320    pub simp: SiMP,
321    /// Filter.
322    pub filter: FilteringMethods,
323    /// Move limit for density update.
324    pub move_limit: f64,
325    /// Damping exponent for OC update.
326    pub damping: f64,
327    /// Convergence tolerance.
328    pub tolerance: f64,
329    /// Maximum iterations.
330    pub max_iter: usize,
331    /// Current compliance history.
332    pub compliance_history: Vec<f64>,
333}
334
335impl TopOptSolver {
336    /// Creates a new `TopOptSolver`.
337    pub fn new(simp: SiMP, filter: FilteringMethods) -> Self {
338        Self {
339            simp,
340            filter,
341            move_limit: 0.2,
342            damping: 0.5,
343            tolerance: 1e-4,
344            max_iter: 200,
345            compliance_history: Vec::new(),
346        }
347    }
348
349    /// Sets the move limit.
350    pub fn with_move_limit(mut self, m: f64) -> Self {
351        self.move_limit = m;
352        self
353    }
354
355    /// Sets the damping parameter.
356    pub fn with_damping(mut self, d: f64) -> Self {
357        self.damping = d;
358        self
359    }
360
361    /// Sets convergence tolerance.
362    pub fn with_tolerance(mut self, t: f64) -> Self {
363        self.tolerance = t;
364        self
365    }
366
367    /// Sets maximum iterations.
368    pub fn with_max_iter(mut self, n: usize) -> Self {
369        self.max_iter = n;
370        self
371    }
372
373    /// Performs one OC density update step.
374    ///
375    /// Returns the new density vector and the Lagrange multiplier used.
376    pub fn oc_update(
377        &self,
378        density: &[f64],
379        sensitivity: &[f64],
380        volume_target: f64,
381    ) -> (Vec<f64>, f64) {
382        let n = density.len();
383        // Bisection for Lagrange multiplier
384        let mut lo = 0.0f64;
385        let mut hi = 1e9f64;
386        let mut new_density = vec![0.0f64; n];
387        for _ in 0..50 {
388            let lmid = 0.5 * (lo + hi);
389            for e in 0..n {
390                let be = (-sensitivity[e] / lmid).max(0.0);
391                let candidate = density[e] * be.powf(self.damping);
392                let lower = (density[e] - self.move_limit).max(0.0);
393                let upper = (density[e] + self.move_limit).min(1.0);
394                new_density[e] = candidate.clamp(lower, upper);
395            }
396            let vol: f64 = new_density.iter().sum::<f64>() / n as f64;
397            if vol > volume_target {
398                lo = lmid;
399            } else {
400                hi = lmid;
401            }
402        }
403        let lmid = 0.5 * (lo + hi);
404        (new_density, lmid)
405    }
406
407    /// Computes compliance given stiffness field and element strain energies.
408    ///
409    /// Compliance = Σ E(ρ_e) * se_e (sum of strain energy contributions).
410    pub fn compute_compliance(&self, strain_energy: &[f64]) -> f64 {
411        strain_energy.iter().sum()
412    }
413
414    /// Runs the topology optimization loop with a mock FEA (diagonal stiffness).
415    ///
416    /// For testing purposes the strain energy of element `e` is approximated
417    /// as `E(ρ_e) * load^2 / n` where `load` is a uniform load parameter.
418    pub fn run_mock(&mut self, load: f64) -> Vec<f64> {
419        let n = self.simp.grid.n_elem();
420        let vf = self.simp.volume_fraction;
421        let mut density = self.simp.density.clone();
422        self.compliance_history.clear();
423
424        for _iter in 0..self.max_iter {
425            // Mock FEA: strain energy proportional to stiffness
426            let se: Vec<f64> = density
427                .iter()
428                .map(|&rho| self.simp.interpolated_stiffness(rho) * load * load / n as f64)
429                .collect();
430            let compliance = self.compute_compliance(&se);
431            self.compliance_history.push(compliance);
432
433            // Sensitivity
434            let raw_sens = self.simp.compliance_sensitivity(&se);
435            // Filter sensitivity
436            let filtered_sens = self.filter.sensitivity_filter(&density, &raw_sens);
437            // OC update
438            let (new_density, _lm) = self.oc_update(&density, &filtered_sens, vf);
439
440            // Check convergence
441            let change: f64 = density
442                .iter()
443                .zip(new_density.iter())
444                .map(|(a, b)| (a - b).abs())
445                .fold(0.0f64, f64::max);
446            density = new_density;
447            if change < self.tolerance {
448                break;
449            }
450        }
451
452        self.simp.density = density.clone();
453        density
454    }
455
456    /// Checks whether the volume constraint is satisfied within tolerance.
457    pub fn volume_constraint_satisfied(&self, density: &[f64], tol: f64) -> bool {
458        let vol: f64 = density.iter().sum::<f64>() / density.len() as f64;
459        (vol - self.simp.volume_fraction).abs() < tol
460    }
461}
462
463// ---------------------------------------------------------------------------
464// MultiLoadTopOpt
465// ---------------------------------------------------------------------------
466
467/// Load case for multi-load topology optimization.
468#[derive(Debug, Clone)]
469pub struct LoadCase {
470    /// Load vector (element-level load proxy values).
471    pub loads: Vec<f64>,
472    /// Weight for this load case in the weighted compliance objective.
473    pub weight: f64,
474}
475
476impl LoadCase {
477    /// Creates a new load case.
478    pub fn new(loads: Vec<f64>, weight: f64) -> Self {
479        Self { loads, weight }
480    }
481}
482
483/// Multi-load case topology optimization.
484///
485/// Minimizes a weighted combination of compliances across multiple load cases.
486#[derive(Debug, Clone)]
487pub struct MultiLoadTopOpt {
488    /// SIMP model.
489    pub simp: SiMP,
490    /// Filter.
491    pub filter: FilteringMethods,
492    /// Load cases.
493    pub load_cases: Vec<LoadCase>,
494    /// Move limit.
495    pub move_limit: f64,
496    /// Damping for OC update.
497    pub damping: f64,
498    /// Compliance history per iteration.
499    pub compliance_history: Vec<f64>,
500}
501
502impl MultiLoadTopOpt {
503    /// Creates a new `MultiLoadTopOpt`.
504    pub fn new(simp: SiMP, filter: FilteringMethods, load_cases: Vec<LoadCase>) -> Self {
505        Self {
506            simp,
507            filter,
508            load_cases,
509            move_limit: 0.2,
510            damping: 0.5,
511            compliance_history: Vec::new(),
512        }
513    }
514
515    /// Computes weighted compliance given current density field.
516    pub fn weighted_compliance(&self, density: &[f64]) -> f64 {
517        let n = density.len();
518        self.load_cases
519            .iter()
520            .map(|lc| {
521                let c: f64 = density
522                    .iter()
523                    .zip(lc.loads.iter())
524                    .map(|(&rho, &f)| self.simp.interpolated_stiffness(rho) * f * f / n as f64)
525                    .sum();
526                lc.weight * c
527            })
528            .sum()
529    }
530
531    /// Computes aggregated sensitivity for all load cases.
532    pub fn aggregated_sensitivity(&self, density: &[f64]) -> Vec<f64> {
533        let n = density.len();
534        let mut agg = vec![0.0f64; n];
535        for lc in &self.load_cases {
536            for (e, (&rho, &f)) in density.iter().zip(lc.loads.iter()).enumerate() {
537                let rho_c = rho.clamp(1e-12, 1.0);
538                let se = self.simp.interpolated_stiffness(rho) * f * f / n as f64;
539                let dsens = -self.simp.penalty
540                    * rho_c.powf(self.simp.penalty - 1.0)
541                    * (self.simp.material.e_solid - self.simp.material.e_void)
542                    * se;
543                agg[e] += lc.weight * dsens;
544            }
545        }
546        agg
547    }
548
549    /// Runs the multi-load topology optimization.
550    pub fn run(&mut self, max_iter: usize, tol: f64) -> Vec<f64> {
551        let n = self.simp.grid.n_elem();
552        let vf = self.simp.volume_fraction;
553        let mut density = self.simp.density.clone();
554        self.compliance_history.clear();
555
556        for _iter in 0..max_iter {
557            let c = self.weighted_compliance(&density);
558            self.compliance_history.push(c);
559
560            let raw_sens = self.aggregated_sensitivity(&density);
561            let filtered_sens = self.filter.sensitivity_filter(&density, &raw_sens);
562
563            // Simple OC bisection
564            let mut lo = 0.0f64;
565            let mut hi = 1e9f64;
566            let mut new_density = vec![0.0f64; n];
567            for _ in 0..50 {
568                let lmid = 0.5 * (lo + hi);
569                for e in 0..n {
570                    let be = (-filtered_sens[e] / lmid).max(0.0);
571                    let candidate = density[e] * be.powf(self.damping);
572                    let lower = (density[e] - self.move_limit).max(0.0);
573                    let upper = (density[e] + self.move_limit).min(1.0);
574                    new_density[e] = candidate.clamp(lower, upper);
575                }
576                let vol: f64 = new_density.iter().sum::<f64>() / n as f64;
577                if vol > vf {
578                    lo = lmid;
579                } else {
580                    hi = lmid;
581                }
582            }
583
584            let change: f64 = density
585                .iter()
586                .zip(new_density.iter())
587                .map(|(a, b)| (a - b).abs())
588                .fold(0.0f64, f64::max);
589            density = new_density;
590            if change < tol {
591                break;
592            }
593        }
594
595        self.simp.density = density.clone();
596        density
597    }
598}
599
600// ---------------------------------------------------------------------------
601// LevelSetTopOpt — Hamilton-Jacobi level set method
602// ---------------------------------------------------------------------------
603
604/// Level-set topology optimization using the Hamilton-Jacobi equation.
605///
606/// The structural boundary is implicitly represented as the zero contour of
607/// the level-set function φ. The Hamilton-Jacobi PDE governs its evolution:
608/// `∂φ/∂t + V_n |∇φ| = 0`.
609#[derive(Debug, Clone)]
610pub struct LevelSetTopOpt {
611    /// Grid size.
612    pub grid: GridSize,
613    /// Level-set function values (one per grid node, length = (nx+1)*(ny+1)).
614    pub phi: Vec<f64>,
615    /// Volume fraction target.
616    pub volume_fraction: f64,
617    /// Time step size for Hamilton-Jacobi update.
618    pub dt: f64,
619    /// Material parameters.
620    pub material: MaterialParams,
621    /// Compliance history.
622    pub compliance_history: Vec<f64>,
623}
624
625impl LevelSetTopOpt {
626    /// Creates a new `LevelSetTopOpt` with a circular initial inclusion.
627    pub fn new(grid: GridSize, volume_fraction: f64, dt: f64, material: MaterialParams) -> Self {
628        let nn = (grid.nx + 1) * (grid.ny + 1);
629        let cx = grid.nx as f64 * 0.5;
630        let cy = grid.ny as f64 * 0.5;
631        let r = (grid.nx.min(grid.ny) as f64) * 0.4;
632        let phi: Vec<f64> = (0..nn)
633            .map(|idx| {
634                let row = idx / (grid.nx + 1);
635                let col = idx % (grid.nx + 1);
636                r - ((col as f64 - cx).powi(2) + (row as f64 - cy).powi(2)).sqrt()
637            })
638            .collect();
639        Self {
640            grid,
641            phi,
642            volume_fraction,
643            dt,
644            material,
645            compliance_history: Vec::new(),
646        }
647    }
648
649    /// Extracts element density from level-set (Heaviside of nodal averages).
650    pub fn element_density(&self) -> Vec<f64> {
651        let nx = self.grid.nx;
652        let ny = self.grid.ny;
653        let nx1 = nx + 1;
654        (0..nx * ny)
655            .map(|e| {
656                let row = e / nx;
657                let col = e % nx;
658                // Four corner nodes of this element
659                let n0 = row * nx1 + col;
660                let n1 = n0 + 1;
661                let n2 = n0 + nx1;
662                let n3 = n2 + 1;
663                let avg = 0.25 * (self.phi[n0] + self.phi[n1] + self.phi[n2] + self.phi[n3]);
664                Self::heaviside(avg, 1.0)
665            })
666            .collect()
667    }
668
669    /// Smooth Heaviside function. Returns values in \[1e-3, 1.0\].
670    fn heaviside(x: f64, eps: f64) -> f64 {
671        if x > eps {
672            1.0
673        } else if x < -eps {
674            1e-3
675        } else {
676            let val = 1e-3 + (1.0 - 1e-3) * (x / eps + (x * PI / eps).sin() / PI) * 0.5;
677            val.clamp(1e-3, 1.0)
678        }
679    }
680
681    /// Dirac delta approximation (derivative of Heaviside).
682    fn dirac(x: f64, eps: f64) -> f64 {
683        if x.abs() > eps {
684            0.0
685        } else {
686            (1.0 - 1e-3) * (1.0 + (x * PI / eps).cos()) / (2.0 * eps)
687        }
688    }
689
690    /// Computes mock compliance given a velocity field.
691    pub fn mock_compliance(&self, velocity: &[f64]) -> f64 {
692        velocity.iter().map(|v| v.powi(2)).sum::<f64>() / velocity.len() as f64
693    }
694
695    /// Performs one Hamilton-Jacobi update step.
696    ///
697    /// `φ_new = φ - dt * V_n * |∇φ|`  (using upwind difference for |∇φ|).
698    pub fn hj_update(&mut self, velocity: &[f64]) {
699        let nx = self.grid.nx;
700        let ny = self.grid.ny;
701        let nx1 = nx + 1;
702        let mut new_phi = self.phi.clone();
703        for row in 0..=ny {
704            for col in 0..=nx {
705                let idx = row * nx1 + col;
706                let vn = if idx < velocity.len() {
707                    velocity[idx]
708                } else {
709                    0.0
710                };
711                // First-order upwind
712                let dpx = if col < nx {
713                    self.phi[idx + 1] - self.phi[idx]
714                } else {
715                    self.phi[idx] - self.phi[idx - 1]
716                };
717                let dpy = if row < ny {
718                    self.phi[idx + nx1] - self.phi[idx]
719                } else {
720                    self.phi[idx] - self.phi[idx - nx1]
721                };
722                let grad_mag = (dpx * dpx + dpy * dpy).sqrt();
723                new_phi[idx] = self.phi[idx] - self.dt * vn * grad_mag;
724            }
725        }
726        self.phi = new_phi;
727    }
728
729    /// Re-initializes phi as a signed distance function using fast-marching approximation.
730    pub fn reinitialize(&mut self, n_iter: usize) {
731        for _ in 0..n_iter {
732            let old = self.phi.clone();
733            let nx = self.grid.nx;
734            let nx1 = nx + 1;
735            let ny = self.grid.ny;
736            for row in 1..ny {
737                for col in 1..nx {
738                    let idx = row * nx1 + col;
739                    let sign = if old[idx] > 0.0 { 1.0 } else { -1.0 };
740                    let dx = 0.5 * ((old[idx + 1] - old[idx - 1]).powi(2)).sqrt().max(0.1);
741                    let dy = 0.5 * ((old[idx + nx1] - old[idx - nx1]).powi(2)).sqrt().max(0.1);
742                    let grad = (dx * dx + dy * dy).sqrt();
743                    self.phi[idx] -= 0.1 * sign * (grad - 1.0);
744                }
745            }
746        }
747    }
748
749    /// Runs mock level-set optimization.
750    pub fn run_mock(&mut self, n_iter: usize) {
751        self.compliance_history.clear();
752        for _i in 0..n_iter {
753            let density = self.element_density();
754            let compliance: f64 = density
755                .iter()
756                .map(|&r| self.material.e_solid * r)
757                .sum::<f64>()
758                / density.len() as f64;
759            self.compliance_history.push(compliance);
760
761            // Mock velocity: negative sensitivity
762            let nn = (self.grid.nx + 1) * (self.grid.ny + 1);
763            let velocity: Vec<f64> = (0..nn)
764                .map(|idx| {
765                    let phi_val = self.phi[idx];
766                    -Self::dirac(phi_val, 1.0)
767                })
768                .collect();
769            self.hj_update(&velocity);
770        }
771    }
772}
773
774// ---------------------------------------------------------------------------
775// ManufacturingFilters
776// ---------------------------------------------------------------------------
777
778/// Manufacturing-aware filters for topology optimization.
779///
780/// Enforces practical constraints such as minimum length scale, overhang
781/// limit for additive manufacturing, and cast/mill/extrude directions.
782#[derive(Debug, Clone)]
783pub struct ManufacturingFilters {
784    /// Grid size.
785    pub grid: GridSize,
786    /// Minimum member size (in element units).
787    pub min_member_size: f64,
788    /// Maximum overhang angle in radians (for additive manufacturing).
789    pub max_overhang_angle: f64,
790    /// Build direction: 0=+y, 1=-y, 2=+x, 3=-x.
791    pub build_direction: usize,
792}
793
794impl ManufacturingFilters {
795    /// Creates a new `ManufacturingFilters`.
796    pub fn new(grid: GridSize, min_member_size: f64, max_overhang_angle: f64) -> Self {
797        Self {
798            grid,
799            min_member_size,
800            max_overhang_angle,
801            build_direction: 0,
802        }
803    }
804
805    /// Sets the build direction.
806    pub fn with_build_direction(mut self, dir: usize) -> Self {
807        self.build_direction = dir;
808        self
809    }
810
811    /// Applies minimum length scale filter by thresholding Heaviside-projected density.
812    ///
813    /// Projects to solid (ρ → 1) if enough neighbors are solid, else void (ρ → 0).
814    pub fn minimum_length_scale(&self, density: &[f64], beta: f64) -> Vec<f64> {
815        let nx = self.grid.nx;
816        let ny = self.grid.ny;
817        let r = self.min_member_size * 0.5;
818        density
819            .iter()
820            .enumerate()
821            .map(|(e, &rho)| {
822                let (row, col) = self.grid.row_col(e);
823                // Count neighbors within radius that are solid
824                let mut count = 0;
825                let mut total = 0;
826                for dr in -(r as i64)..=(r as i64) {
827                    for dc in -(r as i64)..=(r as i64) {
828                        if (dr as f64 * dr as f64 + dc as f64 * dc as f64).sqrt() > r {
829                            continue;
830                        }
831                        let nr = row as i64 + dr;
832                        let nc = col as i64 + dc;
833                        if nr < 0 || nr >= ny as i64 || nc < 0 || nc >= nx as i64 {
834                            continue;
835                        }
836                        let ni = nr as usize * nx + nc as usize;
837                        if density[ni] > 0.5 {
838                            count += 1;
839                        }
840                        total += 1;
841                    }
842                }
843                let frac = if total > 0 {
844                    count as f64 / total as f64
845                } else {
846                    rho
847                };
848                // Smooth threshold
849                1.0 / (1.0 + (-beta * (frac - 0.5)).exp())
850            })
851            .collect()
852    }
853
854    /// Applies overhang constraint filter for additive manufacturing.
855    ///
856    /// Elements that overhang unsupported by more than `max_overhang_angle`
857    /// are penalized by reducing their density.
858    pub fn overhang_filter(&self, density: &[f64]) -> Vec<f64> {
859        let nx = self.grid.nx;
860        let ny = self.grid.ny;
861        let tan_limit = self.max_overhang_angle.tan();
862        density
863            .iter()
864            .enumerate()
865            .map(|(e, &rho)| {
866                let (row, col) = self.grid.row_col(e);
867                // Build direction 0: +y means row below (row-1) must support
868                match self.build_direction {
869                    0 => {
870                        if row == 0 {
871                            return rho; // base plate
872                        }
873                        // Check support from row below
874                        let support_l = if col > 0 {
875                            density[(row - 1) * nx + col - 1]
876                        } else {
877                            0.0
878                        };
879                        let support_c = density[(row - 1) * nx + col];
880                        let support_r = if col + 1 < nx {
881                            density[(row - 1) * nx + col + 1]
882                        } else {
883                            0.0
884                        };
885                        let max_support = support_l.max(support_c).max(support_r);
886                        // Penalize if unsupported
887                        if max_support < 0.3 && rho > 0.5 {
888                            rho * (1.0 - (1.0 - tan_limit).max(0.0))
889                        } else {
890                            rho
891                        }
892                    }
893                    1 => {
894                        if row + 1 >= ny {
895                            return rho;
896                        }
897                        let support = density[(row + 1) * nx + col];
898                        if support < 0.3 && rho > 0.5 {
899                            rho * 0.7
900                        } else {
901                            rho
902                        }
903                    }
904                    2 => {
905                        if col == 0 {
906                            return rho;
907                        }
908                        let support = density[row * nx + col - 1];
909                        if support < 0.3 && rho > 0.5 {
910                            rho * 0.7
911                        } else {
912                            rho
913                        }
914                    }
915                    _ => {
916                        if col + 1 >= nx {
917                            return rho;
918                        }
919                        let support = density[row * nx + col + 1];
920                        if support < 0.3 && rho > 0.5 {
921                            rho * 0.7
922                        } else {
923                            rho
924                        }
925                    }
926                }
927            })
928            .collect()
929    }
930
931    /// Applies casting direction filter (demold constraint).
932    ///
933    /// Ensures no undercuts in the specified build direction by enforcing
934    /// that density is monotonically non-decreasing from the parting line.
935    pub fn casting_filter(&self, density: &[f64]) -> Vec<f64> {
936        let nx = self.grid.nx;
937        let ny = self.grid.ny;
938        let mut result = density.to_vec();
939        match self.build_direction {
940            0 => {
941                // Sweep from bottom (row=0) upward
942                for row in 1..ny {
943                    for col in 0..nx {
944                        let below = result[(row - 1) * nx + col];
945                        let cur = result[row * nx + col];
946                        // If below is solid and current is void: enforce continuity
947                        result[row * nx + col] = cur.max(below * 0.0); // allow void above solid
948                        // Enforce: if above is solid, below must also be solid
949                        let _ = (below, cur);
950                    }
951                }
952            }
953            _ => {
954                // Generic: project from base
955                for col in 1..nx {
956                    for row in 0..ny {
957                        let prev = result[row * nx + col - 1];
958                        result[row * nx + col] = result[row * nx + col].max(prev * 0.0);
959                    }
960                }
961            }
962        }
963        result
964    }
965
966    /// Applies extrusion filter (constant cross-section constraint).
967    ///
968    /// Each column (in build direction) is assigned the maximum density across
969    /// the extrusion depth, enforcing a prismatic shape.
970    pub fn extrusion_filter(&self, density: &[f64], extrude_dir: usize) -> Vec<f64> {
971        let nx = self.grid.nx;
972        let ny = self.grid.ny;
973        let mut result = density.to_vec();
974        if extrude_dir == 0 {
975            // Extrude in x direction: each column gets max over rows
976            for col in 0..nx {
977                let max_val: f64 = (0..ny)
978                    .map(|r| density[r * nx + col])
979                    .fold(0.0f64, f64::max);
980                for row in 0..ny {
981                    result[row * nx + col] = max_val;
982                }
983            }
984        } else {
985            // Extrude in y direction: each row gets max over cols
986            for row in 0..ny {
987                let max_val: f64 = (0..nx)
988                    .map(|c| density[row * nx + c])
989                    .fold(0.0f64, f64::max);
990                for col in 0..nx {
991                    result[row * nx + col] = max_val;
992                }
993            }
994        }
995        result
996    }
997
998    /// Applies milling constraint: ensures no enclosed voids.
999    ///
1000    /// Flood-fills from the boundary and marks all reachable voids as accessible.
1001    pub fn milling_filter(&self, density: &[f64], threshold: f64) -> Vec<f64> {
1002        let nx = self.grid.nx;
1003        let ny = self.grid.ny;
1004        let n = nx * ny;
1005        let mut accessible = vec![false; n];
1006        let mut queue = std::collections::VecDeque::new();
1007
1008        // Seed from boundary
1009        for row in 0..ny {
1010            for col in 0..nx {
1011                if row == 0 || row == ny - 1 || col == 0 || col == nx - 1 {
1012                    let e = row * nx + col;
1013                    if density[e] < threshold {
1014                        accessible[e] = true;
1015                        queue.push_back(e);
1016                    }
1017                }
1018            }
1019        }
1020
1021        // BFS flood fill through voids
1022        while let Some(e) = queue.pop_front() {
1023            let (row, col) = (e / nx, e % nx);
1024            let neighbors = [
1025                if row > 0 {
1026                    Some((row - 1) * nx + col)
1027                } else {
1028                    None
1029                },
1030                if row + 1 < ny {
1031                    Some((row + 1) * nx + col)
1032                } else {
1033                    None
1034                },
1035                if col > 0 {
1036                    Some(row * nx + col - 1)
1037                } else {
1038                    None
1039                },
1040                if col + 1 < nx {
1041                    Some(row * nx + col + 1)
1042                } else {
1043                    None
1044                },
1045            ];
1046            for nbr in neighbors.into_iter().flatten() {
1047                if !accessible[nbr] && density[nbr] < threshold {
1048                    accessible[nbr] = true;
1049                    queue.push_back(nbr);
1050                }
1051            }
1052        }
1053
1054        // Fill enclosed voids with solid
1055        density
1056            .iter()
1057            .enumerate()
1058            .map(|(e, &rho)| {
1059                if rho < threshold && !accessible[e] {
1060                    1.0
1061                } else {
1062                    rho
1063                }
1064            })
1065            .collect()
1066    }
1067}
1068
1069// ---------------------------------------------------------------------------
1070// Utility functions
1071// ---------------------------------------------------------------------------
1072
1073/// Computes the volume fraction of a density field.
1074pub fn volume_fraction(density: &[f64]) -> f64 {
1075    if density.is_empty() {
1076        return 0.0;
1077    }
1078    density.iter().sum::<f64>() / density.len() as f64
1079}
1080
1081/// Normalizes sensitivities to \[-1, 0\] range for stability.
1082pub fn normalize_sensitivity(sensitivity: &mut Vec<f64>) {
1083    let min_s = sensitivity.iter().copied().fold(f64::INFINITY, f64::min);
1084    let max_s = sensitivity
1085        .iter()
1086        .copied()
1087        .fold(f64::NEG_INFINITY, f64::max);
1088    let range = (max_s - min_s).max(1e-12);
1089    for s in sensitivity.iter_mut() {
1090        *s = (*s - min_s) / range - 1.0;
1091    }
1092}
1093
1094/// Computes binary (0/1) design from density field with threshold.
1095pub fn binarize(density: &[f64], threshold: f64) -> Vec<bool> {
1096    density.iter().map(|&r| r >= threshold).collect()
1097}
1098
1099/// Counts number of solid elements in a binary design.
1100pub fn count_solid(binary: &[bool]) -> usize {
1101    binary.iter().filter(|&&b| b).count()
1102}
1103
1104/// Checks if a topology design is connected (solid phase) using BFS.
1105pub fn is_connected(density: &[f64], grid: GridSize, threshold: f64) -> bool {
1106    let nx = grid.nx;
1107    let ny = grid.ny;
1108    let n = nx * ny;
1109    let solid: Vec<bool> = density.iter().map(|&r| r >= threshold).collect();
1110    let start = solid.iter().position(|&s| s);
1111    let Some(start) = start else { return true };
1112
1113    let mut visited = vec![false; n];
1114    let mut queue = std::collections::VecDeque::new();
1115    queue.push_back(start);
1116    visited[start] = true;
1117
1118    while let Some(e) = queue.pop_front() {
1119        let (row, col) = (e / nx, e % nx);
1120        let neighbors = [
1121            if row > 0 {
1122                Some((row - 1) * nx + col)
1123            } else {
1124                None
1125            },
1126            if row + 1 < ny {
1127                Some((row + 1) * nx + col)
1128            } else {
1129                None
1130            },
1131            if col > 0 {
1132                Some(row * nx + col - 1)
1133            } else {
1134                None
1135            },
1136            if col + 1 < nx {
1137                Some(row * nx + col + 1)
1138            } else {
1139                None
1140            },
1141        ];
1142        for nbr in neighbors.into_iter().flatten() {
1143            if solid[nbr] && !visited[nbr] {
1144                visited[nbr] = true;
1145                queue.push_back(nbr);
1146            }
1147        }
1148    }
1149
1150    // All solid elements should be visited
1151    solid.iter().zip(visited.iter()).all(|(&s, &v)| !s || v)
1152}
1153
1154// ---------------------------------------------------------------------------
1155// Tests
1156// ---------------------------------------------------------------------------
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    fn small_grid() -> GridSize {
1163        GridSize::new(4, 4)
1164    }
1165
1166    fn default_material() -> MaterialParams {
1167        MaterialParams::default()
1168    }
1169
1170    // --- GridSize ---
1171
1172    #[test]
1173    fn test_grid_size_n_elem() {
1174        let g = GridSize::new(5, 3);
1175        assert_eq!(g.n_elem(), 15);
1176    }
1177
1178    #[test]
1179    fn test_grid_size_row_col() {
1180        let g = GridSize::new(5, 3);
1181        assert_eq!(g.row_col(7), (1, 2));
1182    }
1183
1184    // --- SiMP ---
1185
1186    #[test]
1187    fn test_simp_initial_density() {
1188        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1189        assert!((simp.current_volume() - 0.5).abs() < 1e-10);
1190    }
1191
1192    #[test]
1193    fn test_simp_stiffness_solid() {
1194        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1195        let e = simp.interpolated_stiffness(1.0);
1196        assert!((e - 1.0).abs() < 1e-10);
1197    }
1198
1199    #[test]
1200    fn test_simp_stiffness_void() {
1201        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1202        let e = simp.interpolated_stiffness(0.0);
1203        assert!((e - 1e-9).abs() < 1e-15);
1204    }
1205
1206    #[test]
1207    fn test_simp_stiffness_interpolation() {
1208        let simp = SiMP::new(small_grid(), 0.5, 2.0, 2.0, default_material());
1209        // For penalty=2, rho=0.5: E = 1e-9 + 0.25 * (1 - 1e-9) ≈ 0.25
1210        let e = simp.interpolated_stiffness(0.5);
1211        assert!((e - 0.25).abs() < 0.01);
1212    }
1213
1214    #[test]
1215    fn test_simp_sensitivity_positive() {
1216        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1217        let s = simp.stiffness_sensitivity(0.5);
1218        assert!(s > 0.0);
1219    }
1220
1221    #[test]
1222    fn test_simp_compliance_sensitivity_negative() {
1223        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1224        let se = vec![1.0; 16];
1225        let cs = simp.compliance_sensitivity(&se);
1226        assert!(cs.iter().all(|&s| s < 0.0));
1227    }
1228
1229    #[test]
1230    fn test_simp_is_valid() {
1231        let simp = SiMP::new(small_grid(), 0.5, 3.0, 2.0, default_material());
1232        assert!(simp.is_valid());
1233    }
1234
1235    #[test]
1236    fn test_simp_stiffness_field_length() {
1237        let grid = small_grid();
1238        let simp = SiMP::new(grid, 0.5, 3.0, 2.0, default_material());
1239        assert_eq!(simp.stiffness_field().len(), grid.n_elem());
1240    }
1241
1242    // --- FilteringMethods ---
1243
1244    #[test]
1245    fn test_density_filter_uniform() {
1246        let grid = GridSize::new(3, 3);
1247        let filter = FilteringMethods::new(1.5, grid);
1248        let density = vec![0.5; 9];
1249        let filtered = filter.density_filter(&density);
1250        assert!(filtered.iter().all(|&r| (r - 0.5).abs() < 1e-10));
1251    }
1252
1253    #[test]
1254    fn test_density_filter_smooths() {
1255        let grid = GridSize::new(5, 5);
1256        let filter = FilteringMethods::new(2.0, grid);
1257        let mut density = vec![0.0; 25];
1258        density[12] = 1.0; // center spike
1259        let filtered = filter.density_filter(&density);
1260        // Surrounding elements should have nonzero filtered density
1261        assert!(filtered[11] > 0.0 || filtered[13] > 0.0);
1262    }
1263
1264    #[test]
1265    fn test_sensitivity_filter_output_length() {
1266        let grid = GridSize::new(3, 3);
1267        let filter = FilteringMethods::new(1.5, grid);
1268        let density = vec![0.5; 9];
1269        let sens = vec![-1.0; 9];
1270        let out = filter.sensitivity_filter(&density, &sens);
1271        assert_eq!(out.len(), 9);
1272    }
1273
1274    #[test]
1275    fn test_heaviside_projection_solid() {
1276        let grid = GridSize::new(2, 2);
1277        let filter = FilteringMethods::new(1.5, grid);
1278        let density = vec![1.0; 4];
1279        let proj = filter.heaviside_projection(&density, 10.0, 0.5);
1280        assert!(proj.iter().all(|&r| r > 0.9));
1281    }
1282
1283    #[test]
1284    fn test_heaviside_projection_void() {
1285        let grid = GridSize::new(2, 2);
1286        let filter = FilteringMethods::new(1.5, grid);
1287        let density = vec![0.0; 4];
1288        let proj = filter.heaviside_projection(&density, 10.0, 0.5);
1289        assert!(proj.iter().all(|&r| r < 0.1));
1290    }
1291
1292    #[test]
1293    fn test_heaviside_sensitivity_positive() {
1294        let grid = GridSize::new(2, 2);
1295        let filter = FilteringMethods::new(1.5, grid);
1296        let density = vec![0.5; 4];
1297        let sens = filter.heaviside_sensitivity(&density, 5.0, 0.5);
1298        assert!(sens.iter().all(|&s| s >= 0.0));
1299    }
1300
1301    #[test]
1302    fn test_filter_sensitivity_chain() {
1303        let grid = GridSize::new(3, 3);
1304        let filter = FilteringMethods::new(1.5, grid);
1305        let sens = vec![1.0; 9];
1306        let density = vec![0.5; 9];
1307        let out = filter.filter_sensitivity_chain(&sens, &density);
1308        assert_eq!(out.len(), 9);
1309    }
1310
1311    // --- TopOptSolver ---
1312
1313    #[test]
1314    fn test_topopt_oc_update_volume() {
1315        let grid = GridSize::new(4, 4);
1316        let simp = SiMP::new(grid, 0.4, 3.0, 1.5, MaterialParams::default());
1317        let filter = FilteringMethods::new(1.5, grid);
1318        let solver = TopOptSolver::new(simp, filter);
1319        let density = vec![0.5; 16];
1320        let sensitivity = vec![-1.0; 16];
1321        let (new_d, _lm) = solver.oc_update(&density, &sensitivity, 0.4);
1322        let vol: f64 = new_d.iter().sum::<f64>() / 16.0;
1323        assert!((vol - 0.4).abs() < 0.05);
1324    }
1325
1326    #[test]
1327    fn test_topopt_run_mock_convergence() {
1328        let grid = GridSize::new(4, 4);
1329        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1330        let filter = FilteringMethods::new(1.5, grid);
1331        let mut solver = TopOptSolver::new(simp, filter).with_max_iter(10);
1332        let result = solver.run_mock(1.0);
1333        assert_eq!(result.len(), 16);
1334        assert!(!solver.compliance_history.is_empty());
1335    }
1336
1337    #[test]
1338    fn test_topopt_compliance_positive() {
1339        let grid = GridSize::new(4, 4);
1340        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1341        let filter = FilteringMethods::new(1.5, grid);
1342        let solver = TopOptSolver::new(simp, filter);
1343        let se = vec![1.0; 16];
1344        let c = solver.compute_compliance(&se);
1345        assert!(c > 0.0);
1346    }
1347
1348    #[test]
1349    fn test_topopt_volume_constraint() {
1350        let grid = GridSize::new(4, 4);
1351        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1352        let filter = FilteringMethods::new(1.5, grid);
1353        let solver = TopOptSolver::new(simp, filter);
1354        let density = vec![0.5; 16];
1355        assert!(solver.volume_constraint_satisfied(&density, 0.01));
1356    }
1357
1358    #[test]
1359    fn test_topopt_with_damping() {
1360        let grid = GridSize::new(4, 4);
1361        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1362        let filter = FilteringMethods::new(1.5, grid);
1363        let solver = TopOptSolver::new(simp, filter).with_damping(0.3);
1364        assert!((solver.damping - 0.3).abs() < 1e-10);
1365    }
1366
1367    // --- MultiLoadTopOpt ---
1368
1369    #[test]
1370    fn test_multiload_weighted_compliance() {
1371        let grid = GridSize::new(4, 4);
1372        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1373        let filter = FilteringMethods::new(1.5, grid);
1374        let lc1 = LoadCase::new(vec![1.0; 16], 0.5);
1375        let lc2 = LoadCase::new(vec![0.5; 16], 0.5);
1376        let solver = MultiLoadTopOpt::new(simp, filter, vec![lc1, lc2]);
1377        let density = vec![0.5; 16];
1378        let c = solver.weighted_compliance(&density);
1379        assert!(c > 0.0);
1380    }
1381
1382    #[test]
1383    fn test_multiload_aggregated_sensitivity_len() {
1384        let grid = GridSize::new(4, 4);
1385        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1386        let filter = FilteringMethods::new(1.5, grid);
1387        let lc = LoadCase::new(vec![1.0; 16], 1.0);
1388        let solver = MultiLoadTopOpt::new(simp, filter, vec![lc]);
1389        let density = vec![0.5; 16];
1390        let sens = solver.aggregated_sensitivity(&density);
1391        assert_eq!(sens.len(), 16);
1392    }
1393
1394    #[test]
1395    fn test_multiload_run() {
1396        let grid = GridSize::new(4, 4);
1397        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1398        let filter = FilteringMethods::new(1.5, grid);
1399        let lc = LoadCase::new(vec![1.0; 16], 1.0);
1400        let mut solver = MultiLoadTopOpt::new(simp, filter, vec![lc]);
1401        let result = solver.run(5, 1e-3);
1402        assert_eq!(result.len(), 16);
1403    }
1404
1405    #[test]
1406    fn test_multiload_equal_weight_symmetry() {
1407        let grid = GridSize::new(2, 2);
1408        let simp = SiMP::new(grid, 0.5, 3.0, 1.5, MaterialParams::default());
1409        let filter = FilteringMethods::new(1.5, grid);
1410        let lc1 = LoadCase::new(vec![1.0; 4], 0.5);
1411        let lc2 = LoadCase::new(vec![1.0; 4], 0.5);
1412        let solver = MultiLoadTopOpt::new(simp.clone(), filter.clone(), vec![lc1, lc2]);
1413        let lc_single = LoadCase::new(vec![1.0; 4], 1.0);
1414        let solver2 = MultiLoadTopOpt::new(simp, filter, vec![lc_single]);
1415        let d = vec![0.5; 4];
1416        let c1 = solver.weighted_compliance(&d);
1417        let c2 = solver2.weighted_compliance(&d);
1418        assert!((c1 - c2).abs() < 1e-10);
1419    }
1420
1421    // --- LevelSetTopOpt ---
1422
1423    #[test]
1424    fn test_levelset_init_phi_length() {
1425        let grid = GridSize::new(4, 4);
1426        let ls = LevelSetTopOpt::new(grid, 0.5, 0.1, MaterialParams::default());
1427        assert_eq!(ls.phi.len(), 25); // (4+1)*(4+1)
1428    }
1429
1430    #[test]
1431    fn test_levelset_element_density_length() {
1432        let grid = GridSize::new(4, 4);
1433        let ls = LevelSetTopOpt::new(grid, 0.5, 0.1, MaterialParams::default());
1434        assert_eq!(ls.element_density().len(), 16);
1435    }
1436
1437    #[test]
1438    fn test_levelset_element_density_range() {
1439        let grid = GridSize::new(4, 4);
1440        let ls = LevelSetTopOpt::new(grid, 0.5, 0.1, MaterialParams::default());
1441        let d = ls.element_density();
1442        assert!(d.iter().all(|&r| (0.0..=1.0).contains(&r)));
1443    }
1444
1445    #[test]
1446    fn test_levelset_hj_update_changes_phi() {
1447        let grid = GridSize::new(4, 4);
1448        let mut ls = LevelSetTopOpt::new(grid, 0.5, 0.1, MaterialParams::default());
1449        let phi_before = ls.phi.clone();
1450        let velocity = vec![1.0; 25];
1451        ls.hj_update(&velocity);
1452        let changed = phi_before
1453            .iter()
1454            .zip(ls.phi.iter())
1455            .any(|(a, b)| (a - b).abs() > 1e-10);
1456        assert!(changed);
1457    }
1458
1459    #[test]
1460    fn test_levelset_run_mock() {
1461        let grid = GridSize::new(4, 4);
1462        let mut ls = LevelSetTopOpt::new(grid, 0.5, 0.01, MaterialParams::default());
1463        ls.run_mock(5);
1464        assert_eq!(ls.compliance_history.len(), 5);
1465    }
1466
1467    #[test]
1468    fn test_levelset_reinitialize() {
1469        let grid = GridSize::new(4, 4);
1470        let mut ls = LevelSetTopOpt::new(grid, 0.5, 0.1, MaterialParams::default());
1471        let phi_before = ls.phi.clone();
1472        ls.reinitialize(3);
1473        // phi should change
1474        let _ = phi_before;
1475    }
1476
1477    // --- ManufacturingFilters ---
1478
1479    #[test]
1480    fn test_mfg_min_length_scale_output_len() {
1481        let grid = GridSize::new(4, 4);
1482        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1483        let density = vec![0.5; 16];
1484        let out = mf.minimum_length_scale(&density, 10.0);
1485        assert_eq!(out.len(), 16);
1486    }
1487
1488    #[test]
1489    fn test_mfg_min_length_scale_range() {
1490        let grid = GridSize::new(4, 4);
1491        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1492        let density = vec![0.5; 16];
1493        let out = mf.minimum_length_scale(&density, 10.0);
1494        assert!(out.iter().all(|&r| (0.0..=1.0).contains(&r)));
1495    }
1496
1497    #[test]
1498    fn test_mfg_overhang_base_unchanged() {
1499        let grid = GridSize::new(4, 4);
1500        let mf = ManufacturingFilters::new(grid, 2.0, PI / 3.0);
1501        let mut density = vec![0.0; 16];
1502        // Set bottom row solid
1503        for col in 0..4 {
1504            density[col] = 1.0;
1505        }
1506        let out = mf.overhang_filter(&density);
1507        // Bottom row (build direction 0) should be unchanged
1508        assert!((out[0] - density[0]).abs() < 1e-10);
1509    }
1510
1511    #[test]
1512    fn test_mfg_casting_filter_len() {
1513        let grid = GridSize::new(4, 4);
1514        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1515        let density = vec![0.5; 16];
1516        let out = mf.casting_filter(&density);
1517        assert_eq!(out.len(), 16);
1518    }
1519
1520    #[test]
1521    fn test_mfg_extrusion_filter_x() {
1522        let grid = GridSize::new(4, 4);
1523        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1524        let mut density = vec![0.0; 16];
1525        density[0] = 1.0; // only top-left
1526        let out = mf.extrusion_filter(&density, 0); // extrude in x
1527        // column 0 should all be 1.0
1528        for row in 0..4 {
1529            assert!((out[row * 4] - 1.0).abs() < 1e-10);
1530        }
1531    }
1532
1533    #[test]
1534    fn test_mfg_extrusion_filter_y() {
1535        let grid = GridSize::new(4, 4);
1536        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1537        let mut density = vec![0.0; 16];
1538        density[0] = 1.0; // only top-left corner
1539        let out = mf.extrusion_filter(&density, 1); // extrude in y
1540        // row 0 should all be 1.0
1541        for col in 0..4 {
1542            assert!((out[col] - 1.0).abs() < 1e-10);
1543        }
1544    }
1545
1546    #[test]
1547    fn test_mfg_milling_filter_no_enclosed() {
1548        let grid = GridSize::new(4, 4);
1549        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1550        let density = vec![0.0; 16]; // all void = all accessible
1551        let out = mf.milling_filter(&density, 0.5);
1552        assert!(out.iter().all(|&r| r < 0.5)); // all remain void
1553    }
1554
1555    #[test]
1556    fn test_mfg_milling_filter_fills_enclosed() {
1557        let grid = GridSize::new(5, 5);
1558        let mf = ManufacturingFilters::new(grid, 2.0, PI / 4.0);
1559        // Ring of solid around center void
1560        let mut density = vec![1.0; 25];
1561        density[12] = 0.0; // center void
1562        let out = mf.milling_filter(&density, 0.5);
1563        // The center should be filled (enclosed)
1564        assert!(out[12] >= 0.5);
1565    }
1566
1567    // --- Utility functions ---
1568
1569    #[test]
1570    fn test_volume_fraction() {
1571        let d = vec![0.5; 10];
1572        assert!((volume_fraction(&d) - 0.5).abs() < 1e-10);
1573    }
1574
1575    #[test]
1576    fn test_volume_fraction_empty() {
1577        assert_eq!(volume_fraction(&[]), 0.0);
1578    }
1579
1580    #[test]
1581    fn test_normalize_sensitivity() {
1582        let mut s = vec![-4.0, -2.0, -1.0];
1583        normalize_sensitivity(&mut s);
1584        let max_s = s.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1585        let min_s = s.iter().copied().fold(f64::INFINITY, f64::min);
1586        assert!(max_s <= 0.0);
1587        assert!(min_s >= -1.0);
1588    }
1589
1590    #[test]
1591    fn test_binarize() {
1592        let d = vec![0.3, 0.7, 0.5, 0.1];
1593        let b = binarize(&d, 0.5);
1594        assert_eq!(b, vec![false, true, true, false]);
1595    }
1596
1597    #[test]
1598    fn test_count_solid() {
1599        let b = vec![true, false, true, true];
1600        assert_eq!(count_solid(&b), 3);
1601    }
1602
1603    #[test]
1604    fn test_is_connected_uniform() {
1605        let grid = GridSize::new(3, 3);
1606        let density = vec![1.0; 9];
1607        assert!(is_connected(&density, grid, 0.5));
1608    }
1609
1610    #[test]
1611    fn test_is_connected_disconnected() {
1612        let grid = GridSize::new(3, 3);
1613        let mut density = vec![0.0; 9];
1614        density[0] = 1.0;
1615        density[8] = 1.0; // corners only — not connected in 4-connectivity
1616        assert!(!is_connected(&density, grid, 0.5));
1617    }
1618
1619    #[test]
1620    fn test_is_connected_all_void() {
1621        let grid = GridSize::new(3, 3);
1622        let density = vec![0.0; 9];
1623        assert!(is_connected(&density, grid, 0.5)); // vacuously true
1624    }
1625}