Skip to main content

oxiphysics_geometry/
architectural_geometry.rs

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