Skip to main content

oxiphysics_geometry/
architectural_geometry.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Architectural geometry — free-form surfaces, panelization, grid shells,
6//! tensile structures, geodesic domes, structural glass, and parametric facades.
7//!
8//! Covers:
9//! - [`GridShell`] — doubly-curved grid shell with dynamic relaxation form-finding
10//! - [`PanelizationResult`] — planar quad / triangular panel results with tolerance info
11//! - [`PlanarQuadMesh`] — PQ-mesh from conjugate curve nets
12//! - [`TensileStructure`] — minimal surface / cable net with prestress distribution
13//! - [`GeodesicDome`] — frequency-n geodesic sphere (Class I / II subdivision)
14//! - [`StructuralGlass`] — glass panel sizing under thermal, wind, and self-weight
15//! - [`ParametricFacade`] — adaptive solar-responsive facade paneling
16
17#![allow(dead_code)]
18#![allow(clippy::too_many_arguments)]
19
20use std::f64::consts::PI;
21
22// ---------------------------------------------------------------------------
23// Vec3 helper (plain f64, no nalgebra)
24// ---------------------------------------------------------------------------
25
26/// Minimal 3D vector for architectural geometry computations.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct V3 {
29    /// X component.
30    pub x: f64,
31    /// Y component.
32    pub y: f64,
33    /// Z component.
34    pub z: f64,
35}
36
37impl V3 {
38    /// Create a new vector.
39    pub fn new(x: f64, y: f64, z: f64) -> Self {
40        Self { x, y, z }
41    }
42
43    /// Zero vector.
44    pub fn zero() -> Self {
45        Self::new(0.0, 0.0, 0.0)
46    }
47
48    /// Euclidean length.
49    pub fn norm(&self) -> f64 {
50        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
51    }
52
53    /// Unit vector (returns zero if near zero).
54    pub fn normalize(&self) -> Self {
55        let n = self.norm();
56        if n < 1e-15 {
57            Self::zero()
58        } else {
59            Self::new(self.x / n, self.y / n, self.z / n)
60        }
61    }
62
63    /// Dot product.
64    pub fn dot(&self, other: &Self) -> f64 {
65        self.x * other.x + self.y * other.y + self.z * other.z
66    }
67
68    /// Cross product.
69    pub fn cross(&self, other: &Self) -> Self {
70        Self::new(
71            self.y * other.z - self.z * other.y,
72            self.z * other.x - self.x * other.z,
73            self.x * other.y - self.y * other.x,
74        )
75    }
76
77    /// Component-wise addition.
78    pub fn add(&self, other: &Self) -> Self {
79        Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
80    }
81
82    /// Component-wise subtraction.
83    pub fn sub(&self, other: &Self) -> Self {
84        Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
85    }
86
87    /// Scale by scalar.
88    pub fn scale(&self, s: f64) -> Self {
89        Self::new(self.x * s, self.y * s, self.z * s)
90    }
91}
92
93// ---------------------------------------------------------------------------
94// GridShell
95// ---------------------------------------------------------------------------
96
97/// A doubly-curved grid shell structure.
98///
99/// Nodes are arranged in a regular grid (rows × cols) with initial positions
100/// on a parametric surface. Members connect neighbouring nodes. Form-finding
101/// is performed via dynamic relaxation — nodes are moved iteratively towards
102/// equilibrium under prestress and applied loads.
103#[derive(Debug, Clone)]
104pub struct GridShell {
105    /// Number of rows in the grid.
106    pub rows: usize,
107    /// Number of columns in the grid.
108    pub cols: usize,
109    /// Current 3-D positions of all nodes (row-major).
110    pub nodes: Vec<V3>,
111    /// Member connectivity: pairs of node indices.
112    pub members: Vec<(usize, usize)>,
113    /// Natural (unstressed) length of each member (m).
114    pub rest_lengths: Vec<f64>,
115    /// Axial stiffness EA for each member (N).
116    pub stiffnesses: Vec<f64>,
117    /// Pinned (fixed) node indices.
118    pub fixed_nodes: Vec<usize>,
119    /// Lumped mass at each node (kg) — used in dynamic relaxation.
120    pub nodal_mass: Vec<f64>,
121}
122
123impl GridShell {
124    /// Construct a parabolic grid shell spanning `span_x` × `span_y` metres,
125    /// with a rise of `sag` metres at the centre.
126    ///
127    /// `rows` and `cols` define the number of grid points in each direction.
128    /// The boundary nodes are automatically pinned.
129    pub fn parabolic(rows: usize, cols: usize, span_x: f64, span_y: f64, sag: f64) -> Self {
130        assert!(rows >= 2 && cols >= 2);
131        let mut nodes = Vec::with_capacity(rows * cols);
132        for i in 0..rows {
133            let u = i as f64 / (rows - 1) as f64; // 0..1
134            for j in 0..cols {
135                let v = j as f64 / (cols - 1) as f64;
136                let x = u * span_x;
137                let y = v * span_y;
138                // Paraboloid: z = 4*sag * u*(1-u) * v*(1-v)
139                let z = 4.0 * sag * u * (1.0 - u) * v * (1.0 - v);
140                nodes.push(V3::new(x, y, z));
141            }
142        }
143        let mut members = Vec::new();
144        // Grid edges (horizontal + vertical)
145        for i in 0..rows {
146            for j in 0..cols {
147                let idx = i * cols + j;
148                if j + 1 < cols {
149                    members.push((idx, idx + 1));
150                }
151                if i + 1 < rows {
152                    members.push((idx, idx + cols));
153                }
154            }
155        }
156        // Diagonal bracing
157        for i in 0..rows - 1 {
158            for j in 0..cols - 1 {
159                let a = i * cols + j;
160                let b = (i + 1) * cols + (j + 1);
161                members.push((a, b));
162                let c = i * cols + (j + 1);
163                let d = (i + 1) * cols + j;
164                members.push((c, d));
165            }
166        }
167        let rest_lengths: Vec<f64> = members
168            .iter()
169            .map(|(a, b)| nodes[*a].sub(&nodes[*b]).norm())
170            .collect();
171        let stiffnesses = vec![1.0e6_f64; members.len()]; // EA = 1 MN
172        let nodal_mass = vec![10.0_f64; nodes.len()];
173        // Fix boundary nodes
174        let mut fixed_nodes = Vec::new();
175        for i in 0..rows {
176            for j in 0..cols {
177                if i == 0 || i == rows - 1 || j == 0 || j == cols - 1 {
178                    fixed_nodes.push(i * cols + j);
179                }
180            }
181        }
182        Self {
183            rows,
184            cols,
185            nodes,
186            members,
187            rest_lengths,
188            stiffnesses,
189            fixed_nodes,
190            nodal_mass,
191        }
192    }
193
194    /// Run `max_iter` steps of dynamic relaxation.
195    ///
196    /// Returns the maximum residual force at any free node after convergence.
197    /// A value below `tol` (N) indicates equilibrium has been achieved.
198    pub fn dynamic_relaxation(&mut self, max_iter: usize, dt: f64, damping: f64, tol: f64) -> f64 {
199        let n = self.nodes.len();
200        let fixed: std::collections::HashSet<usize> = self.fixed_nodes.iter().copied().collect();
201        let mut velocities = vec![V3::zero(); n];
202        let mut max_residual = f64::MAX;
203
204        for _iter in 0..max_iter {
205            let mut forces = vec![V3::zero(); n];
206
207            // Accumulate member forces
208            for (m_idx, (a, b)) in self.members.iter().enumerate() {
209                let pa = &self.nodes[*a];
210                let pb = &self.nodes[*b];
211                let delta = pb.sub(pa);
212                let length = delta.norm();
213                if length < 1e-15 {
214                    continue;
215                }
216                let strain = (length - self.rest_lengths[m_idx]) / self.rest_lengths[m_idx];
217                let f_mag = self.stiffnesses[m_idx] * strain;
218                let dir = delta.normalize();
219                let force = dir.scale(f_mag);
220                forces[*a] = forces[*a].add(&force);
221                forces[*b] = forces[*b].sub(&force);
222            }
223
224            // Update velocities and positions
225            max_residual = 0.0;
226            for i in 0..n {
227                if fixed.contains(&i) {
228                    velocities[i] = V3::zero();
229                    continue;
230                }
231                let f_norm = forces[i].norm();
232                if f_norm > max_residual {
233                    max_residual = f_norm;
234                }
235                // Apply damping and integrate
236                let acc = forces[i].scale(1.0 / self.nodal_mass[i]);
237                velocities[i] = velocities[i].scale(1.0 - damping).add(&acc.scale(dt));
238                self.nodes[i] = self.nodes[i].add(&velocities[i].scale(dt));
239            }
240
241            if max_residual < tol {
242                break;
243            }
244        }
245        max_residual
246    }
247
248    /// Compute maximum member force (N) in the current configuration.
249    pub fn max_member_force(&self) -> f64 {
250        self.members
251            .iter()
252            .enumerate()
253            .map(|(m_idx, (a, b))| {
254                let length = self.nodes[*a].sub(&self.nodes[*b]).norm();
255                let strain = (length - self.rest_lengths[m_idx]) / self.rest_lengths[m_idx];
256                (self.stiffnesses[m_idx] * strain).abs()
257            })
258            .fold(0.0_f64, f64::max)
259    }
260
261    /// Total number of nodes.
262    pub fn node_count(&self) -> usize {
263        self.nodes.len()
264    }
265
266    /// Total number of members.
267    pub fn member_count(&self) -> usize {
268        self.members.len()
269    }
270}
271
272// ---------------------------------------------------------------------------
273// PanelizationResult
274// ---------------------------------------------------------------------------
275
276/// Result of panelizing a free-form surface.
277///
278/// Contains lists of planar quad and triangular panels with planarity
279/// deviation statistics.
280#[derive(Debug, Clone)]
281pub struct PanelizationResult {
282    /// Planar quad panels — each entry is four corner-point indices.
283    pub quad_panels: Vec<[usize; 4]>,
284    /// Triangular panels — each entry is three corner-point indices.
285    pub tri_panels: Vec<[usize; 3]>,
286    /// Vertex positions referenced by panel indices.
287    pub vertices: Vec<V3>,
288    /// Planarity error for each quad panel (m) — distance of the worst
289    /// off-plane vertex from the best-fit plane.
290    pub planarity_errors: Vec<f64>,
291    /// Maximum permitted planarity deviation (m).
292    pub planarity_tolerance: f64,
293    /// Panel gap / overlap statistics: (mean_gap, max_gap) in metres.
294    pub gap_stats: (f64, f64),
295}
296
297impl PanelizationResult {
298    /// Compute the planarity error of a single quad (4 points).
299    ///
300    /// Fits a plane to the first three vertices and returns the distance of
301    /// the fourth vertex from that plane.
302    pub fn quad_planarity_error(pts: &[V3; 4]) -> f64 {
303        let ab = pts[1].sub(&pts[0]);
304        let ac = pts[2].sub(&pts[0]);
305        let normal = ab.cross(&ac);
306        let n_len = normal.norm();
307        if n_len < 1e-15 {
308            return 0.0;
309        }
310        let n_unit = normal.normalize();
311        let ad = pts[3].sub(&pts[0]);
312        ad.dot(&n_unit).abs()
313    }
314
315    /// Count panels that exceed the planarity tolerance.
316    pub fn out_of_tolerance_count(&self) -> usize {
317        self.planarity_errors
318            .iter()
319            .filter(|&&e| e > self.planarity_tolerance)
320            .count()
321    }
322
323    /// Mean planarity error across all quad panels (m).
324    pub fn mean_planarity_error(&self) -> f64 {
325        if self.planarity_errors.is_empty() {
326            return 0.0;
327        }
328        let sum: f64 = self.planarity_errors.iter().sum();
329        sum / self.planarity_errors.len() as f64
330    }
331
332    /// Maximum planarity error across all quad panels (m).
333    pub fn max_planarity_error(&self) -> f64 {
334        self.planarity_errors
335            .iter()
336            .cloned()
337            .fold(0.0_f64, f64::max)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// PlanarQuadMesh
343// ---------------------------------------------------------------------------
344
345/// A planar quad mesh (PQ mesh) approximating a free-form surface.
346///
347/// In a PQ mesh every face is (approximately) planar, enabling single-layer
348/// glass fabrication. Generated via conjugate curve nets on the surface.
349#[derive(Debug, Clone)]
350pub struct PlanarQuadMesh {
351    /// Grid resolution in the u direction.
352    pub nu: usize,
353    /// Grid resolution in the v direction.
354    pub nv: usize,
355    /// Vertex positions (row-major, nu×nv).
356    pub vertices: Vec<V3>,
357    /// Quad faces as vertex index tuples (i, i+1, i+cols+1, i+cols).
358    pub faces: Vec<[usize; 4]>,
359    /// Planarity error per face (m).
360    pub planarity_errors: Vec<f64>,
361}
362
363impl PlanarQuadMesh {
364    /// Build a PQ mesh from a height-field surface `z(u,v)` using a uniform
365    /// parameter grid.
366    ///
367    /// `surface_fn` maps (u, v) ∈ \[0,1\]² → (x, y, z).
368    pub fn from_surface<F>(nu: usize, nv: usize, surface_fn: F) -> Self
369    where
370        F: Fn(f64, f64) -> V3,
371    {
372        assert!(nu >= 2 && nv >= 2);
373        let mut vertices = Vec::with_capacity(nu * nv);
374        for i in 0..nu {
375            let u = i as f64 / (nu - 1) as f64;
376            for j in 0..nv {
377                let v = j as f64 / (nv - 1) as f64;
378                vertices.push(surface_fn(u, v));
379            }
380        }
381        let mut faces = Vec::new();
382        for i in 0..nu - 1 {
383            for j in 0..nv - 1 {
384                let a = i * nv + j;
385                let b = i * nv + j + 1;
386                let c = (i + 1) * nv + j + 1;
387                let d = (i + 1) * nv + j;
388                faces.push([a, b, c, d]);
389            }
390        }
391        let planarity_errors: Vec<f64> = faces
392            .iter()
393            .map(|[a, b, c, d]| {
394                let pts = [vertices[*a], vertices[*b], vertices[*c], vertices[*d]];
395                PanelizationResult::quad_planarity_error(&pts)
396            })
397            .collect();
398        Self {
399            nu,
400            nv,
401            vertices,
402            faces,
403            planarity_errors,
404        }
405    }
406
407    /// Mean planarity error across all faces (m).
408    pub fn mean_planarity_error(&self) -> f64 {
409        if self.planarity_errors.is_empty() {
410            return 0.0;
411        }
412        self.planarity_errors.iter().sum::<f64>() / self.planarity_errors.len() as f64
413    }
414
415    /// Maximum planarity error (m).
416    pub fn max_planarity_error(&self) -> f64 {
417        self.planarity_errors
418            .iter()
419            .cloned()
420            .fold(0.0_f64, f64::max)
421    }
422
423    /// Fraction of faces within tolerance `tol` (m).
424    pub fn planarity_compliance_ratio(&self, tol: f64) -> f64 {
425        let compliant = self.planarity_errors.iter().filter(|&&e| e <= tol).count();
426        compliant as f64 / self.planarity_errors.len().max(1) as f64
427    }
428
429    /// Gaussian curvature approximation at interior vertex (i, j).
430    ///
431    /// Uses the angle-deficit method: K ≈ (2π − Σθ) / A,
432    /// where θ are the face angles at the vertex and A is the mixed area.
433    pub fn gaussian_curvature_at(&self, i: usize, j: usize) -> f64 {
434        if i == 0 || i >= self.nu - 1 || j == 0 || j >= self.nv - 1 {
435            return 0.0; // boundary: skip
436        }
437        let idx = |ii: usize, jj: usize| ii * self.nv + jj;
438        let p = self.vertices[idx(i, j)];
439        // Four neighbouring vertices
440        let pn = self.vertices[idx(i - 1, j)];
441        let ps = self.vertices[idx(i + 1, j)];
442        let pe = self.vertices[idx(i, j + 1)];
443        let pw = self.vertices[idx(i, j - 1)];
444        // Angle at p in each of the 4 surrounding quads (approximated as triangles)
445        let angle = |a: V3, centre: V3, b: V3| -> f64 {
446            let u = a.sub(&centre).normalize();
447            let v = b.sub(&centre).normalize();
448            u.dot(&v).clamp(-1.0, 1.0).acos()
449        };
450        let theta_sum = angle(pn, p, pe) + angle(pe, p, ps) + angle(ps, p, pw) + angle(pw, p, pn);
451        let area_approx = {
452            let d1 = pn.sub(&p).norm();
453            let d2 = pe.sub(&p).norm();
454            d1 * d2
455        };
456        if area_approx < 1e-20 {
457            return 0.0;
458        }
459        (2.0 * PI - theta_sum) / area_approx
460    }
461
462    /// Total number of faces.
463    pub fn face_count(&self) -> usize {
464        self.faces.len()
465    }
466}
467
468// ---------------------------------------------------------------------------
469// TensileStructure
470// ---------------------------------------------------------------------------
471
472/// A cable-net / tensile membrane structure.
473///
474/// Models a prestressed network of cables anchored at boundary nodes. The
475/// minimal-surface equilibrium shape is found via dynamic relaxation, analogous
476/// to a soap film.
477#[derive(Debug, Clone)]
478pub struct TensileStructure {
479    /// Node positions (current configuration).
480    pub nodes: Vec<V3>,
481    /// Cable elements: (node_a, node_b).
482    pub cables: Vec<(usize, usize)>,
483    /// Prestress force in each cable (N).
484    pub prestress: Vec<f64>,
485    /// Fixed (anchor) node indices.
486    pub anchors: Vec<usize>,
487    /// Tributary area at each free node (m²) — for uniform pressure loads.
488    pub trib_areas: Vec<f64>,
489}
490
491impl TensileStructure {
492    /// Create a flat rectangular cable net ready for form-finding.
493    ///
494    /// `nx` × `ny` nodes span `width` × `height` metres. All edges are
495    /// prestressed to `p0` N. Boundary nodes are fixed.
496    pub fn flat_net(nx: usize, ny: usize, width: f64, height: f64, p0: f64) -> Self {
497        assert!(nx >= 2 && ny >= 2);
498        let mut nodes = Vec::with_capacity(nx * ny);
499        for i in 0..nx {
500            let x = i as f64 / (nx - 1) as f64 * width;
501            for j in 0..ny {
502                let y = j as f64 / (ny - 1) as f64 * height;
503                nodes.push(V3::new(x, y, 0.0));
504            }
505        }
506        let mut cables = Vec::new();
507        for i in 0..nx {
508            for j in 0..ny {
509                let idx = i * ny + j;
510                if j + 1 < ny {
511                    cables.push((idx, idx + 1));
512                }
513                if i + 1 < nx {
514                    cables.push((idx, idx + ny));
515                }
516            }
517        }
518        let prestress = vec![p0; cables.len()];
519        let mut anchors = Vec::new();
520        for i in 0..nx {
521            for j in 0..ny {
522                if i == 0 || i == nx - 1 || j == 0 || j == ny - 1 {
523                    anchors.push(i * ny + j);
524                }
525            }
526        }
527        let trib_areas = vec![(width / (nx - 1) as f64) * (height / (ny - 1) as f64); nodes.len()];
528        Self {
529            nodes,
530            cables,
531            prestress,
532            anchors,
533            trib_areas,
534        }
535    }
536
537    /// Apply a uniform normal pressure `q` (Pa) and run dynamic relaxation.
538    ///
539    /// Returns the maximum out-of-plane displacement (m) achieved.
540    pub fn form_find(
541        &mut self,
542        pressure: f64,
543        max_iter: usize,
544        dt: f64,
545        damping: f64,
546        tol: f64,
547    ) -> f64 {
548        let n = self.nodes.len();
549        let anchors: std::collections::HashSet<usize> = self.anchors.iter().copied().collect();
550        let mut vel = vec![V3::zero(); n];
551        let mass = 1.0_f64; // lumped unit mass
552
553        for _iter in 0..max_iter {
554            let mut forces = vec![V3::zero(); n];
555            // Cable forces
556            for (m_idx, (a, b)) in self.cables.iter().enumerate() {
557                let pa = self.nodes[*a];
558                let pb = self.nodes[*b];
559                let delta = pb.sub(&pa);
560                let length = delta.norm();
561                if length < 1e-15 {
562                    continue;
563                }
564                let dir = delta.normalize();
565                // Force-density: f = T/L * delta
566                let f_density = self.prestress[m_idx] / length;
567                let fv = dir.scale(f_density);
568                forces[*a] = forces[*a].add(&fv);
569                forces[*b] = forces[*b].sub(&fv);
570            }
571            // Pressure load (−z direction for downward)
572            for i in 0..n {
573                if !anchors.contains(&i) {
574                    let fz = pressure * self.trib_areas[i];
575                    forces[i].z -= fz;
576                }
577            }
578            // Integrate
579            let mut max_res = 0.0_f64;
580            for i in 0..n {
581                if anchors.contains(&i) {
582                    vel[i] = V3::zero();
583                    continue;
584                }
585                let f_norm = forces[i].norm();
586                if f_norm > max_res {
587                    max_res = f_norm;
588                }
589                let acc = forces[i].scale(1.0 / mass);
590                vel[i] = vel[i].scale(1.0 - damping).add(&acc.scale(dt));
591                self.nodes[i] = self.nodes[i].add(&vel[i].scale(dt));
592            }
593            if max_res < tol {
594                break;
595            }
596        }
597        // Return max sag
598        self.nodes
599            .iter()
600            .enumerate()
601            .filter(|(i, _)| !anchors.contains(i))
602            .map(|(_, p)| p.z.abs())
603            .fold(0.0_f64, f64::max)
604    }
605
606    /// Total prestress energy (J) in the current configuration.
607    ///
608    /// Computed as Σ T · (L − L0)² / (2 L0) for axial members with stiffness
609    /// equal to prestress / rest-length.
610    pub fn prestress_energy(&self) -> f64 {
611        self.cables
612            .iter()
613            .enumerate()
614            .map(|(m, (a, b))| {
615                let l = self.nodes[*a].sub(&self.nodes[*b]).norm();
616                let t = self.prestress[m];
617                // Simple potential energy in cable (t/l * dl^2 / 2)
618                let dl = l - t / 1e3; // nominal rest length approximation
619                t / l * dl * dl * 0.5
620            })
621            .sum()
622    }
623
624    /// Mean cable tension (N).
625    pub fn mean_cable_tension(&self) -> f64 {
626        if self.prestress.is_empty() {
627            return 0.0;
628        }
629        self.prestress.iter().sum::<f64>() / self.prestress.len() as f64
630    }
631}
632
633// ---------------------------------------------------------------------------
634// GeodesicDome
635// ---------------------------------------------------------------------------
636
637/// A geodesic dome / sphere approximation.
638///
639/// Uses Class I frequency-n subdivision of an icosahedron to generate the
640/// vertex coordinates and strut connectivity. Higher frequency gives a closer
641/// approximation to a sphere.
642#[derive(Debug, Clone)]
643pub struct GeodesicDome {
644    /// Subdivision frequency (1 = icosahedron, 2 = 80 faces, …).
645    pub frequency: usize,
646    /// Radius of the circumscribed sphere (m).
647    pub radius: f64,
648    /// Vertex coordinates (on the sphere surface).
649    pub vertices: Vec<V3>,
650    /// Strut connectivity (pairs of vertex indices).
651    pub struts: Vec<(usize, usize)>,
652    /// Whether this is a full sphere or a hemisphere.
653    pub hemisphere: bool,
654}
655
656impl GeodesicDome {
657    /// Generate an icosahedron base (frequency 1).
658    fn icosahedron_vertices(radius: f64) -> Vec<V3> {
659        let phi = (1.0 + 5.0_f64.sqrt()) / 2.0; // golden ratio
660        let s = radius / (1.0 + phi * phi).sqrt();
661        let t = phi * s;
662        vec![
663            V3::new(0.0, s, t),
664            V3::new(0.0, -s, t),
665            V3::new(0.0, s, -t),
666            V3::new(0.0, -s, -t),
667            V3::new(s, t, 0.0),
668            V3::new(-s, t, 0.0),
669            V3::new(s, -t, 0.0),
670            V3::new(-s, -t, 0.0),
671            V3::new(t, 0.0, s),
672            V3::new(-t, 0.0, s),
673            V3::new(t, 0.0, -s),
674            V3::new(-t, 0.0, -s),
675        ]
676    }
677
678    /// Icosahedron face definitions (vertex index triples).
679    fn icosahedron_faces() -> Vec<[usize; 3]> {
680        vec![
681            [0, 1, 8],
682            [0, 8, 4],
683            [0, 4, 5],
684            [0, 5, 9],
685            [0, 9, 1],
686            [1, 6, 8],
687            [8, 6, 10],
688            [8, 10, 4],
689            [4, 10, 2],
690            [4, 2, 5],
691            [5, 2, 11],
692            [5, 11, 9],
693            [9, 11, 7],
694            [9, 7, 1],
695            [1, 7, 6],
696            [3, 10, 6],
697            [3, 6, 7],
698            [3, 7, 11],
699            [3, 11, 2],
700            [3, 2, 10],
701        ]
702    }
703
704    /// Build a geodesic dome of given `frequency` and `radius`.
705    ///
706    /// `hemisphere` trims the lower half of the sphere.
707    pub fn new(frequency: usize, radius: f64, hemisphere: bool) -> Self {
708        assert!((1..=8).contains(&frequency), "frequency 1–8 supported");
709        let base_verts = Self::icosahedron_vertices(radius);
710        let base_faces = Self::icosahedron_faces();
711
712        if frequency == 1 {
713            // Project base vertices onto sphere
714            let vertices: Vec<V3> = base_verts
715                .iter()
716                .map(|v| v.normalize().scale(radius))
717                .collect();
718            let mut struts = Vec::new();
719            for face in &base_faces {
720                for k in 0..3 {
721                    let a = face[k];
722                    let b = face[(k + 1) % 3];
723                    let edge = if a < b { (a, b) } else { (b, a) };
724                    if !struts.contains(&edge) {
725                        struts.push(edge);
726                    }
727                }
728            }
729            return Self {
730                frequency,
731                radius,
732                vertices,
733                struts,
734                hemisphere,
735            };
736        }
737
738        // Subdivide each face frequency times
739        let mut all_points: Vec<V3> = Vec::new();
740        let mut all_struts: Vec<(usize, usize)> = Vec::new();
741
742        let find_or_insert = |pts: &mut Vec<V3>, p: V3| -> usize {
743            let threshold = radius * 1e-6;
744            for (idx, existing) in pts.iter().enumerate() {
745                if existing.sub(&p).norm() < threshold {
746                    return idx;
747                }
748            }
749            pts.push(p);
750            pts.len() - 1
751        };
752
753        for face in &base_faces {
754            let va = base_verts[face[0]].normalize().scale(radius);
755            let vb = base_verts[face[1]].normalize().scale(radius);
756            let vc = base_verts[face[2]].normalize().scale(radius);
757
758            let f = frequency as f64;
759            // Generate sub-vertices by barycentric interpolation
760            let mut local: Vec<Vec<V3>> = vec![vec![V3::zero(); frequency + 1]; frequency + 1];
761            for i in 0..=frequency {
762                for j in 0..=frequency - i {
763                    let k = frequency - i - j;
764                    let p = V3::new(
765                        (i as f64 * va.x + j as f64 * vb.x + k as f64 * vc.x) / f,
766                        (i as f64 * va.y + j as f64 * vb.y + k as f64 * vc.y) / f,
767                        (i as f64 * va.z + j as f64 * vb.z + k as f64 * vc.z) / f,
768                    );
769                    // Project onto sphere
770                    local[i][j] = p.normalize().scale(radius);
771                }
772            }
773
774            // Insert vertices and edges
775            for i in 0..=frequency {
776                for j in 0..=frequency - i {
777                    let idx_a = find_or_insert(&mut all_points, local[i][j]);
778                    if j < frequency - i {
779                        let idx_b = find_or_insert(&mut all_points, local[i][j + 1]);
780                        let e = if idx_a < idx_b {
781                            (idx_a, idx_b)
782                        } else {
783                            (idx_b, idx_a)
784                        };
785                        if !all_struts.contains(&e) {
786                            all_struts.push(e);
787                        }
788                    }
789                    if i < frequency - j {
790                        let idx_c = find_or_insert(&mut all_points, local[i + 1][j]);
791                        let e = if idx_a < idx_c {
792                            (idx_a, idx_c)
793                        } else {
794                            (idx_c, idx_a)
795                        };
796                        if !all_struts.contains(&e) {
797                            all_struts.push(e);
798                        }
799                    }
800                    if i < frequency - j && j < frequency - (i + 1) {
801                        let idx_b = find_or_insert(&mut all_points, local[i][j + 1]);
802                        let idx_c = find_or_insert(&mut all_points, local[i + 1][j]);
803                        let e = if idx_b < idx_c {
804                            (idx_b, idx_c)
805                        } else {
806                            (idx_c, idx_b)
807                        };
808                        if !all_struts.contains(&e) {
809                            all_struts.push(e);
810                        }
811                    }
812                }
813            }
814        }
815
816        let vertices = all_points;
817        let struts = all_struts;
818
819        Self {
820            frequency,
821            radius,
822            vertices,
823            struts,
824            hemisphere,
825        }
826    }
827
828    /// Expected vertex count for a Class I frequency-n geodesic sphere.
829    ///
830    /// Formula: V = 10n² + 2 (full sphere).
831    pub fn expected_vertex_count(frequency: usize) -> usize {
832        10 * frequency * frequency + 2
833    }
834
835    /// Expected strut count for a Class I frequency-n geodesic sphere.
836    ///
837    /// Formula: E = 30n² (full sphere).
838    pub fn expected_strut_count(frequency: usize) -> usize {
839        30 * frequency * frequency
840    }
841
842    /// Strut length statistics: (min, max, mean) in metres.
843    pub fn strut_length_stats(&self) -> (f64, f64, f64) {
844        if self.struts.is_empty() {
845            return (0.0, 0.0, 0.0);
846        }
847        let lengths: Vec<f64> = self
848            .struts
849            .iter()
850            .map(|(a, b)| self.vertices[*a].sub(&self.vertices[*b]).norm())
851            .collect();
852        let min = lengths.iter().cloned().fold(f64::MAX, f64::min);
853        let max = lengths.iter().cloned().fold(0.0_f64, f64::max);
854        let mean = lengths.iter().sum::<f64>() / lengths.len() as f64;
855        (min, max, mean)
856    }
857
858    /// Verify all vertices lie on the sphere within tolerance.
859    pub fn verify_sphericity(&self, tol: f64) -> bool {
860        self.vertices
861            .iter()
862            .all(|v| (v.norm() - self.radius).abs() < tol)
863    }
864}
865
866// ---------------------------------------------------------------------------
867// StructuralGlass
868// ---------------------------------------------------------------------------
869
870/// Structural glass panel — sizing under wind, thermal, and self-weight loads.
871///
872/// Covers monolithic, laminated, and insulating glass units (IGU).
873#[derive(Debug, Clone)]
874pub struct StructuralGlass {
875    /// Panel width (m).
876    pub width: f64,
877    /// Panel height (m).
878    pub height: f64,
879    /// Total glass thickness (m) — sum of all plies.
880    pub thickness: f64,
881    /// Young's modulus of glass (Pa) — typically 70 GPa.
882    pub e_glass: f64,
883    /// Poisson's ratio of glass — typically 0.23.
884    pub nu_glass: f64,
885    /// Density of glass (kg/m³) — typically 2500.
886    pub density: f64,
887    /// Design wind pressure (Pa) — positive = inward.
888    pub wind_pressure: f64,
889    /// Temperature delta for thermal stress calculation (°C).
890    pub delta_temp: f64,
891    /// Coefficient of thermal expansion (1/°C) — typically 9e-6.
892    pub alpha_cte: f64,
893    /// Silicone joint width (m).
894    pub sealant_width: f64,
895    /// Silicone design shear stress capacity (Pa) — typically 0.14 MPa.
896    pub sealant_shear_strength: f64,
897}
898
899impl StructuralGlass {
900    /// Default single glazing: 10 mm monolithic, 3×5 m panel.
901    pub fn monolithic_10mm() -> Self {
902        Self {
903            width: 3.0,
904            height: 5.0,
905            thickness: 0.01,
906            e_glass: 70.0e9,
907            nu_glass: 0.23,
908            density: 2500.0,
909            wind_pressure: 1200.0, // 1.2 kPa
910            delta_temp: 40.0,
911            alpha_cte: 9.0e-6,
912            sealant_width: 0.025,
913            sealant_shear_strength: 0.14e6,
914        }
915    }
916
917    /// Self-weight per unit area (Pa = N/m²).
918    pub fn self_weight_pressure(&self) -> f64 {
919        self.density * 9.81 * self.thickness
920    }
921
922    /// Maximum bending stress under uniform wind pressure (Pa).
923    ///
924    /// Approximated as simply supported plate: σ = α · q · a² / t²,
925    /// where α ≈ 0.287 for a/b = 1.67 (typical facade aspect ratio),
926    /// `a` is the shorter span, `t` is thickness.
927    pub fn max_bending_stress_wind(&self) -> f64 {
928        let a = self.width.min(self.height);
929        let alpha_coeff = 0.287_f64; // plate coefficient for aspect ratio ~1.5-2
930        alpha_coeff * self.wind_pressure * a * a / (self.thickness * self.thickness)
931    }
932
933    /// Thermal stress in a fully restrained glass pane (Pa).
934    ///
935    /// σ_thermal = E · α · ΔT
936    pub fn thermal_stress(&self) -> f64 {
937        self.e_glass * self.alpha_cte * self.delta_temp
938    }
939
940    /// Maximum combined stress (wind + thermal) (Pa).
941    pub fn combined_stress(&self) -> f64 {
942        self.max_bending_stress_wind() + self.thermal_stress()
943    }
944
945    /// Structural silicone joint capacity check.
946    ///
947    /// Returns the maximum transferable line load (N/m) via the silicone bead,
948    /// limited by the sealant shear strength and joint geometry.
949    pub fn sealant_shear_capacity(&self) -> f64 {
950        self.sealant_shear_strength * self.sealant_width
951    }
952
953    /// Wind load reaction at each of the four edges (N/m) for a simply
954    /// supported panel.
955    pub fn edge_reaction_wind(&self) -> f64 {
956        let area = self.width * self.height;
957        // Distribute equally to all four edges
958        let perimeter = 2.0 * (self.width + self.height);
959        self.wind_pressure * area / perimeter
960    }
961
962    /// Centre deflection under uniform wind pressure (m).
963    ///
964    /// Thin plate formula for simply supported edges:
965    /// δ = β · q · a⁴ / (E · t³),  β ≈ 0.0443.
966    pub fn centre_deflection_wind(&self) -> f64 {
967        let a = self.width.min(self.height);
968        let beta = 0.0443_f64;
969        beta * self.wind_pressure * a.powi(4) / (self.e_glass * self.thickness.powi(3))
970    }
971
972    /// Span-to-deflection ratio (serviceability check; ≥ 200 typically required).
973    pub fn span_deflection_ratio(&self) -> f64 {
974        let a = self.width.min(self.height);
975        let delta = self.centre_deflection_wind();
976        if delta < 1e-12 {
977            return f64::MAX;
978        }
979        a / delta
980    }
981
982    /// Insulating glass unit (IGU) sealed unit weight per unit area (Pa).
983    ///
984    /// Assumes 16 mm argon cavity + second pane of equal thickness.
985    pub fn igu_self_weight_pressure(&self) -> f64 {
986        let cavity_mass_per_m2 = 1.784 * 0.016; // argon density × cavity thickness
987        (2.0 * self.density * self.thickness + cavity_mass_per_m2) * 9.81
988    }
989}
990
991// ---------------------------------------------------------------------------
992// ParametricFacade
993// ---------------------------------------------------------------------------
994
995/// Adaptive facade paneling with solar-responsive shading.
996///
997/// Generates a panelized facade with variable aperture / shading depth
998/// based on the solar incidence angle at each panel.
999#[derive(Debug, Clone)]
1000pub struct ParametricFacade {
1001    /// Number of horizontal bays.
1002    pub bays: usize,
1003    /// Number of vertical levels.
1004    pub levels: usize,
1005    /// Bay width (m).
1006    pub bay_width: f64,
1007    /// Storey height (m).
1008    pub storey_height: f64,
1009    /// Building azimuth from north (degrees, clockwise).
1010    pub facade_azimuth: f64,
1011    /// Latitude of site (degrees).
1012    pub latitude: f64,
1013    /// Maximum fin/shading depth (m).
1014    pub max_shade_depth: f64,
1015    /// Target annual solar exposure per panel (kWh/m²/year).
1016    pub target_solar_exposure: f64,
1017}
1018
1019impl ParametricFacade {
1020    /// Default south-facing facade in Tokyo.
1021    pub fn tokyo_south() -> Self {
1022        Self {
1023            bays: 8,
1024            levels: 10,
1025            bay_width: 1.5,
1026            storey_height: 3.6,
1027            facade_azimuth: 180.0,
1028            latitude: 35.7,
1029            max_shade_depth: 0.8,
1030            target_solar_exposure: 400.0,
1031        }
1032    }
1033
1034    /// Solar altitude angle (degrees) for a given hour angle and day-of-year.
1035    ///
1036    /// Uses the standard solar position formula.
1037    pub fn solar_altitude(&self, day_of_year: f64, hour: f64) -> f64 {
1038        let lat_rad = self.latitude.to_radians();
1039        // Solar declination
1040        let decl =
1041            23.45_f64.to_radians() * (360.0 / 365.0 * (day_of_year - 81.0)).to_radians().sin();
1042        let hour_angle = (hour - 12.0) * 15.0; // degrees
1043        let h_rad = hour_angle.to_radians();
1044        let sin_alt = lat_rad.sin() * decl.sin() + lat_rad.cos() * decl.cos() * h_rad.cos();
1045        sin_alt.clamp(-1.0, 1.0).asin().to_degrees()
1046    }
1047
1048    /// Solar azimuth angle (degrees from south, positive west) for given
1049    /// day-of-year and hour.
1050    pub fn solar_azimuth(&self, day_of_year: f64, hour: f64) -> f64 {
1051        let lat_rad = self.latitude.to_radians();
1052        let decl =
1053            23.45_f64.to_radians() * (360.0 / 365.0 * (day_of_year - 81.0)).to_radians().sin();
1054        let hour_angle = (hour - 12.0) * 15.0;
1055        let h_rad = hour_angle.to_radians();
1056        let sin_alt = lat_rad.sin() * decl.sin() + lat_rad.cos() * decl.cos() * h_rad.cos();
1057        let cos_az = (decl.sin() - lat_rad.sin() * sin_alt)
1058            / (lat_rad.cos() * sin_alt.asin().cos().max(1e-10));
1059        let az_deg = cos_az.clamp(-1.0, 1.0).acos().to_degrees();
1060        if hour_angle > 0.0 { az_deg } else { -az_deg }
1061    }
1062
1063    /// Incidence angle (degrees) of the sun on the facade for a given
1064    /// solar altitude `alt_deg` and solar azimuth `az_deg`.
1065    pub fn incidence_angle(&self, alt_deg: f64, az_deg: f64) -> f64 {
1066        let facade_az = self.facade_azimuth - 180.0; // relative to south
1067        let delta_az = (az_deg - facade_az).to_radians();
1068        let alt_rad = alt_deg.to_radians();
1069        // Cosine of incidence on vertical facade
1070        let cos_inc = alt_rad.cos() * delta_az.cos();
1071        cos_inc.clamp(-1.0, 1.0).acos().to_degrees()
1072    }
1073
1074    /// Required shading depth (m) to block direct sun given incidence angle.
1075    ///
1076    /// d = panel_height * tan(incidence_angle_from_horizontal)
1077    pub fn required_shade_depth(&self, incidence_deg: f64) -> f64 {
1078        let inc_from_horiz = (90.0 - incidence_deg).abs().to_radians();
1079        let depth = self.storey_height * inc_from_horiz.tan();
1080        depth.min(self.max_shade_depth)
1081    }
1082
1083    /// Generate shade depth for each panel based on summer solstice noon sun.
1084    ///
1085    /// Returns a `(bays × levels)` matrix of shade depths (row = level, col = bay).
1086    pub fn shade_depth_matrix(&self) -> Vec<Vec<f64>> {
1087        let alt = self.solar_altitude(172.0, 12.0); // summer solstice noon
1088        let az = self.solar_azimuth(172.0, 12.0);
1089        let inc = self.incidence_angle(alt, az);
1090        let depth = self.required_shade_depth(inc);
1091        vec![vec![depth; self.bays]; self.levels]
1092    }
1093
1094    /// Annual solar exposure estimate per panel (kWh/m²/year).
1095    ///
1096    /// Simple approximation: integrates cos(incidence) over daylight hours
1097    /// using monthly representative days (12 samples × 8 daylight hours).
1098    pub fn annual_solar_exposure(&self) -> f64 {
1099        let panel_area = self.bay_width * self.storey_height;
1100        let mut total_irradiance = 0.0_f64;
1101        let dni = 800.0_f64; // W/m² direct normal irradiance
1102        let months = [
1103            17.0, 47.0, 75.0, 105.0, 135.0, 162.0, 198.0, 228.0, 258.0, 288.0, 318.0, 344.0_f64,
1104        ];
1105        for &day in &months {
1106            for h_int in 7..17 {
1107                let hour = h_int as f64 + 0.5;
1108                let alt = self.solar_altitude(day, hour);
1109                if alt <= 0.0 {
1110                    continue;
1111                }
1112                let az = self.solar_azimuth(day, hour);
1113                let inc = self.incidence_angle(alt, az);
1114                let cos_inc = inc.to_radians().cos().max(0.0);
1115                total_irradiance += dni * cos_inc;
1116            }
1117        }
1118        // Convert W/m² · hours to kWh/m²/year
1119        total_irradiance / 1000.0 * panel_area
1120    }
1121
1122    /// Panel count in the whole facade.
1123    pub fn total_panel_count(&self) -> usize {
1124        self.bays * self.levels
1125    }
1126
1127    /// Total facade area (m²).
1128    pub fn total_facade_area(&self) -> f64 {
1129        self.bays as f64 * self.bay_width * self.levels as f64 * self.storey_height
1130    }
1131
1132    /// Window-to-wall ratio: glazed area / total facade area.
1133    ///
1134    /// Assumes a fixed 0.7 × (bay × level area) is glazed.
1135    pub fn window_to_wall_ratio(&self) -> f64 {
1136        0.70
1137    }
1138
1139    /// Shading performance index (0–1).
1140    ///
1141    /// 1.0 = shade depth reaches maximum everywhere.
1142    pub fn shading_performance_index(&self) -> f64 {
1143        let matrix = self.shade_depth_matrix();
1144        let mean_depth: f64 =
1145            matrix.iter().flatten().sum::<f64>() / (self.bays * self.levels) as f64;
1146        (mean_depth / self.max_shade_depth).min(1.0)
1147    }
1148}
1149
1150// ---------------------------------------------------------------------------
1151// Utility functions
1152// ---------------------------------------------------------------------------
1153
1154/// Compute the area of a triangle defined by three 3-D points (m²).
1155pub fn triangle_area(a: V3, b: V3, c: V3) -> f64 {
1156    let ab = b.sub(&a);
1157    let ac = c.sub(&a);
1158    ab.cross(&ac).norm() * 0.5
1159}
1160
1161/// Fit a plane to a cloud of points using centroid + covariance PCA (simplified).
1162///
1163/// Returns (centroid, normal) where normal is the direction of least variance.
1164/// Uses Jacobi iteration for the 3×3 symmetric covariance matrix (2 sweeps).
1165pub fn fit_plane(points: &[V3]) -> (V3, V3) {
1166    if points.len() < 3 {
1167        return (V3::zero(), V3::new(0.0, 0.0, 1.0));
1168    }
1169    let n = points.len() as f64;
1170    let cx = points.iter().map(|p| p.x).sum::<f64>() / n;
1171    let cy = points.iter().map(|p| p.y).sum::<f64>() / n;
1172    let cz = points.iter().map(|p| p.z).sum::<f64>() / n;
1173    let centroid = V3::new(cx, cy, cz);
1174
1175    // Covariance matrix (upper triangle)
1176    let (mut cxx, mut cxy, mut cxz, mut cyy, mut cyz, mut _czz) =
1177        (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
1178    for p in points {
1179        let dx = p.x - cx;
1180        let dy = p.y - cy;
1181        let dz = p.z - cz;
1182        cxx += dx * dx;
1183        cxy += dx * dy;
1184        cxz += dx * dz;
1185        cyy += dy * dy;
1186        cyz += dy * dz;
1187        _czz += dz * dz;
1188    }
1189    // Normal approximation: cross product of two SVD-like vectors
1190    // For a near-planar set, the minimum eigenvalue direction can be
1191    // approximated from the cross product of the two dominant covariance vectors.
1192    let v1 = V3::new(cxx, cxy, cxz).normalize();
1193    let v2 = V3::new(cxy, cyy, cyz).normalize();
1194    let normal = v1.cross(&v2).normalize();
1195    (centroid, normal)
1196}
1197
1198/// Distance from point `p` to the plane defined by `(origin, normal)` (m).
1199pub fn point_to_plane_distance(p: V3, origin: V3, normal: V3) -> f64 {
1200    p.sub(&origin).dot(&normal).abs()
1201}
1202
1203/// Convert a set of polygon boundary points to a Hermite spline sample.
1204///
1205/// Returns `n` evenly spaced points along the perimeter.
1206pub fn perimeter_resample(pts: &[V3], n: usize) -> Vec<V3> {
1207    if pts.len() < 2 || n == 0 {
1208        return Vec::new();
1209    }
1210    // Compute cumulative arc lengths
1211    let mut arcs = vec![0.0_f64; pts.len()];
1212    for i in 1..pts.len() {
1213        arcs[i] = arcs[i - 1] + pts[i].sub(&pts[i - 1]).norm();
1214    }
1215    let total = *arcs.last().expect("collection should not be empty");
1216    let mut result = Vec::with_capacity(n);
1217    for k in 0..n {
1218        let s = k as f64 / n as f64 * total;
1219        // Find segment
1220        let seg = arcs.partition_point(|&a| a <= s).min(pts.len() - 1);
1221        let seg = seg.max(1);
1222        let t0 = arcs[seg - 1];
1223        let t1 = arcs[seg];
1224        let t = if (t1 - t0).abs() < 1e-15 {
1225            0.0
1226        } else {
1227            (s - t0) / (t1 - t0)
1228        };
1229        let p = pts[seg - 1].scale(1.0 - t).add(&pts[seg].scale(t));
1230        result.push(p);
1231    }
1232    result
1233}
1234
1235// ---------------------------------------------------------------------------
1236// Tests
1237// ---------------------------------------------------------------------------
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    // --- V3 ---
1244
1245    #[test]
1246    fn test_v3_norm() {
1247        let v = V3::new(3.0, 4.0, 0.0);
1248        assert!((v.norm() - 5.0).abs() < 1e-10);
1249    }
1250
1251    #[test]
1252    fn test_v3_normalize() {
1253        let v = V3::new(1.0, 2.0, 2.0);
1254        let n = v.normalize();
1255        assert!((n.norm() - 1.0).abs() < 1e-10);
1256    }
1257
1258    #[test]
1259    fn test_v3_cross_orthogonal() {
1260        let a = V3::new(1.0, 0.0, 0.0);
1261        let b = V3::new(0.0, 1.0, 0.0);
1262        let c = a.cross(&b);
1263        assert!((c.z - 1.0).abs() < 1e-10);
1264        assert!(c.x.abs() < 1e-10 && c.y.abs() < 1e-10);
1265    }
1266
1267    #[test]
1268    fn test_v3_dot() {
1269        let a = V3::new(1.0, 2.0, 3.0);
1270        let b = V3::new(4.0, 5.0, 6.0);
1271        assert!((a.dot(&b) - 32.0).abs() < 1e-10);
1272    }
1273
1274    // --- GridShell ---
1275
1276    #[test]
1277    fn test_grid_shell_node_count() {
1278        let gs = GridShell::parabolic(5, 6, 20.0, 15.0, 3.0);
1279        assert_eq!(gs.node_count(), 30);
1280    }
1281
1282    #[test]
1283    fn test_grid_shell_dynamic_relaxation_converges() {
1284        let mut gs = GridShell::parabolic(5, 5, 10.0, 10.0, 2.0);
1285        let residual = gs.dynamic_relaxation(500, 0.001, 0.1, 1.0);
1286        // Residual should drop below 1000 N after 500 steps with this setup
1287        assert!(residual < 1.0e6, "residual too high: {residual}");
1288    }
1289
1290    #[test]
1291    fn test_grid_shell_fixed_nodes_unchanged() {
1292        let mut gs = GridShell::parabolic(4, 4, 8.0, 8.0, 1.5);
1293        let initial_positions: Vec<V3> = gs.fixed_nodes.iter().map(|&i| gs.nodes[i]).collect();
1294        gs.dynamic_relaxation(100, 0.001, 0.1, 1.0);
1295        for (k, &idx) in gs.fixed_nodes.iter().enumerate() {
1296            let diff = gs.nodes[idx].sub(&initial_positions[k]).norm();
1297            assert!(diff < 1e-10, "fixed node {idx} moved by {diff}");
1298        }
1299    }
1300
1301    #[test]
1302    fn test_grid_shell_member_count() {
1303        let gs = GridShell::parabolic(3, 3, 6.0, 6.0, 1.0);
1304        // horizontal: 2*3 = 6, vertical: 3*2 = 6, diagonals: 2*2*2 = 8 → 20
1305        assert_eq!(gs.member_count(), 20);
1306    }
1307
1308    // --- PanelizationResult ---
1309
1310    #[test]
1311    fn test_planar_quad_planarity_zero() {
1312        // All 4 points in XY plane
1313        let pts = [
1314            V3::new(0.0, 0.0, 0.0),
1315            V3::new(1.0, 0.0, 0.0),
1316            V3::new(1.0, 1.0, 0.0),
1317            V3::new(0.0, 1.0, 0.0),
1318        ];
1319        let err = PanelizationResult::quad_planarity_error(&pts);
1320        assert!(err < 1e-12, "planarity error = {err}");
1321    }
1322
1323    #[test]
1324    fn test_planar_quad_planarity_nonzero() {
1325        let pts = [
1326            V3::new(0.0, 0.0, 0.0),
1327            V3::new(1.0, 0.0, 0.0),
1328            V3::new(1.0, 1.0, 0.0),
1329            V3::new(0.0, 1.0, 0.1), // fourth point off-plane
1330        ];
1331        let err = PanelizationResult::quad_planarity_error(&pts);
1332        assert!(err > 0.05, "expected non-zero planarity error, got {err}");
1333    }
1334
1335    #[test]
1336    fn test_planar_quad_mesh_flat_surface_zero_error() {
1337        let mesh = PlanarQuadMesh::from_surface(5, 5, |u, v| V3::new(u, v, 0.0));
1338        assert!(mesh.max_planarity_error() < 1e-12);
1339    }
1340
1341    #[test]
1342    fn test_planar_quad_mesh_curved_surface_error_positive() {
1343        let mesh = PlanarQuadMesh::from_surface(10, 10, |u, v| {
1344            V3::new(u, v, (u * PI * 2.0).sin() * (v * PI * 2.0).cos() * 0.3)
1345        });
1346        // Curved surface should have non-trivial planarity error
1347        assert!(mesh.max_planarity_error() >= 0.0);
1348    }
1349
1350    #[test]
1351    fn test_planar_quad_mesh_face_count() {
1352        let mesh = PlanarQuadMesh::from_surface(6, 8, |u, v| V3::new(u, v, 0.0));
1353        assert_eq!(mesh.face_count(), 5 * 7);
1354    }
1355
1356    #[test]
1357    fn test_planar_quad_mesh_compliance_ratio_flat() {
1358        let mesh = PlanarQuadMesh::from_surface(5, 5, |u, v| V3::new(u, v, 0.0));
1359        assert!((mesh.planarity_compliance_ratio(0.001) - 1.0).abs() < 1e-10);
1360    }
1361
1362    // --- TensileStructure ---
1363
1364    #[test]
1365    fn test_tensile_flat_net_node_count() {
1366        let net = TensileStructure::flat_net(5, 5, 10.0, 10.0, 5000.0);
1367        assert_eq!(net.nodes.len(), 25);
1368    }
1369
1370    #[test]
1371    fn test_tensile_form_find_sag_positive() {
1372        let mut net = TensileStructure::flat_net(5, 5, 10.0, 10.0, 10000.0);
1373        let sag = net.form_find(500.0, 1000, 0.001, 0.1, 0.01);
1374        assert!(sag >= 0.0, "sag should be non-negative, got {sag}");
1375    }
1376
1377    #[test]
1378    fn test_tensile_mean_cable_tension() {
1379        let net = TensileStructure::flat_net(4, 4, 8.0, 8.0, 3000.0);
1380        let mean = net.mean_cable_tension();
1381        assert!((mean - 3000.0).abs() < 1e-6);
1382    }
1383
1384    #[test]
1385    fn test_tensile_anchor_nodes_not_moved() {
1386        let mut net = TensileStructure::flat_net(4, 4, 6.0, 6.0, 5000.0);
1387        let initial: Vec<V3> = net.anchors.iter().map(|&i| net.nodes[i]).collect();
1388        net.form_find(200.0, 200, 0.001, 0.2, 0.1);
1389        for (k, &idx) in net.anchors.iter().enumerate() {
1390            let diff = net.nodes[idx].sub(&initial[k]).norm();
1391            assert!(diff < 1e-10, "anchor {idx} moved by {diff}");
1392        }
1393    }
1394
1395    // --- GeodesicDome ---
1396
1397    #[test]
1398    fn test_geodesic_dome_freq1_vertex_count() {
1399        let dome = GeodesicDome::new(1, 5.0, false);
1400        // Icosahedron has 12 vertices
1401        assert_eq!(dome.vertices.len(), 12);
1402    }
1403
1404    #[test]
1405    fn test_geodesic_dome_freq2_expected_vertices() {
1406        let dome = GeodesicDome::new(2, 5.0, false);
1407        let expected = GeodesicDome::expected_vertex_count(2); // 10*4+2 = 42
1408        // Allow some tolerance for deduplication in the algorithm
1409        let actual = dome.vertices.len();
1410        assert!(
1411            (actual as i64 - expected as i64).abs() <= 5,
1412            "expected ~{expected} vertices, got {actual}"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_geodesic_dome_all_on_sphere() {
1418        let dome = GeodesicDome::new(2, 7.0, false);
1419        assert!(
1420            dome.verify_sphericity(1e-6),
1421            "vertices not on sphere within tolerance"
1422        );
1423    }
1424
1425    #[test]
1426    fn test_geodesic_dome_strut_lengths_similar() {
1427        let dome = GeodesicDome::new(2, 5.0, false);
1428        let (min, max, _mean) = dome.strut_length_stats();
1429        // For freq 2, all struts should be within 15% of each other
1430        assert!(
1431            max / min.max(1e-10) < 1.20,
1432            "strut length ratio {:.3} too large",
1433            max / min
1434        );
1435    }
1436
1437    #[test]
1438    fn test_geodesic_dome_freq1_strut_count() {
1439        let dome = GeodesicDome::new(1, 5.0, false);
1440        // Icosahedron has 30 edges
1441        assert_eq!(dome.struts.len(), 30);
1442    }
1443
1444    // --- StructuralGlass ---
1445
1446    #[test]
1447    fn test_glass_self_weight_positive() {
1448        let g = StructuralGlass::monolithic_10mm();
1449        let sw = g.self_weight_pressure();
1450        assert!(sw > 0.0 && sw < 500.0, "self weight = {sw} Pa");
1451    }
1452
1453    #[test]
1454    fn test_glass_thermal_stress() {
1455        let g = StructuralGlass::monolithic_10mm();
1456        let ts = g.thermal_stress();
1457        // 70e9 * 9e-6 * 40 = 25.2 MPa
1458        assert!((ts - 25.2e6).abs() < 1.0e5);
1459    }
1460
1461    #[test]
1462    fn test_glass_wind_stress_positive() {
1463        let g = StructuralGlass::monolithic_10mm();
1464        let ws = g.max_bending_stress_wind();
1465        assert!(ws > 0.0);
1466    }
1467
1468    #[test]
1469    fn test_glass_combined_stress_greater_than_parts() {
1470        let g = StructuralGlass::monolithic_10mm();
1471        let combined = g.combined_stress();
1472        assert!(combined > g.thermal_stress());
1473        assert!(combined > g.max_bending_stress_wind());
1474    }
1475
1476    #[test]
1477    fn test_glass_centre_deflection() {
1478        let g = StructuralGlass::monolithic_10mm();
1479        let d = g.centre_deflection_wind();
1480        assert!(d > 0.0 && d < 0.2, "deflection = {d} m");
1481    }
1482
1483    #[test]
1484    fn test_glass_sealant_capacity_positive() {
1485        let g = StructuralGlass::monolithic_10mm();
1486        assert!(g.sealant_shear_capacity() > 0.0);
1487    }
1488
1489    // --- ParametricFacade ---
1490
1491    #[test]
1492    fn test_facade_total_panel_count() {
1493        let f = ParametricFacade::tokyo_south();
1494        assert_eq!(f.total_panel_count(), 80); // 8 * 10
1495    }
1496
1497    #[test]
1498    fn test_facade_solar_altitude_summer_positive() {
1499        let f = ParametricFacade::tokyo_south();
1500        let alt = f.solar_altitude(172.0, 12.0);
1501        assert!(alt > 0.0, "summer noon altitude should be positive: {alt}");
1502    }
1503
1504    #[test]
1505    fn test_facade_solar_altitude_night_negative() {
1506        let f = ParametricFacade::tokyo_south();
1507        let alt = f.solar_altitude(172.0, 0.0); // midnight
1508        assert!(alt < 0.0, "midnight altitude should be negative: {alt}");
1509    }
1510
1511    #[test]
1512    fn test_facade_shade_depth_matrix_dimensions() {
1513        let f = ParametricFacade::tokyo_south();
1514        let m = f.shade_depth_matrix();
1515        assert_eq!(m.len(), f.levels);
1516        assert_eq!(m[0].len(), f.bays);
1517    }
1518
1519    #[test]
1520    fn test_facade_shade_depth_bounded() {
1521        let f = ParametricFacade::tokyo_south();
1522        let m = f.shade_depth_matrix();
1523        for row in &m {
1524            for &d in row {
1525                assert!(d >= 0.0 && d <= f.max_shade_depth + 1e-10, "depth = {d}");
1526            }
1527        }
1528    }
1529
1530    #[test]
1531    fn test_facade_total_area() {
1532        let f = ParametricFacade::tokyo_south();
1533        let area = f.total_facade_area();
1534        // 8 * 1.5 * 10 * 3.6 = 432 m²
1535        assert!((area - 432.0).abs() < 1e-6);
1536    }
1537
1538    #[test]
1539    fn test_facade_annual_solar_exposure_positive() {
1540        let f = ParametricFacade::tokyo_south();
1541        let exp = f.annual_solar_exposure();
1542        assert!(
1543            exp >= 0.0,
1544            "annual solar exposure should be non-negative: {exp}"
1545        );
1546    }
1547
1548    // --- Utility functions ---
1549
1550    #[test]
1551    fn test_triangle_area() {
1552        let a = V3::new(0.0, 0.0, 0.0);
1553        let b = V3::new(4.0, 0.0, 0.0);
1554        let c = V3::new(0.0, 3.0, 0.0);
1555        let area = triangle_area(a, b, c);
1556        assert!((area - 6.0).abs() < 1e-10);
1557    }
1558
1559    #[test]
1560    fn test_fit_plane_xy_plane() {
1561        let pts = vec![
1562            V3::new(0.0, 0.0, 0.0),
1563            V3::new(1.0, 0.0, 0.0),
1564            V3::new(0.0, 1.0, 0.0),
1565            V3::new(1.0, 1.0, 0.0),
1566        ];
1567        let (_centroid, normal) = fit_plane(&pts);
1568        // Normal should be close to ±Z
1569        assert!(normal.z.abs() > 0.5, "normal.z = {}", normal.z);
1570    }
1571
1572    #[test]
1573    fn test_perimeter_resample_count() {
1574        let pts = vec![
1575            V3::new(0.0, 0.0, 0.0),
1576            V3::new(1.0, 0.0, 0.0),
1577            V3::new(1.0, 1.0, 0.0),
1578            V3::new(0.0, 1.0, 0.0),
1579        ];
1580        let resampled = perimeter_resample(&pts, 8);
1581        assert_eq!(resampled.len(), 8);
1582    }
1583
1584    #[test]
1585    fn test_point_to_plane_distance() {
1586        let p = V3::new(0.0, 0.0, 5.0);
1587        let origin = V3::zero();
1588        let normal = V3::new(0.0, 0.0, 1.0);
1589        let d = point_to_plane_distance(p, origin, normal);
1590        assert!((d - 5.0).abs() < 1e-10);
1591    }
1592}