← All Pull Requests

Add explicit 3D solid thermal analysis and named GLB temperature surfaces #12

Closed opened by John Lauer
Merges solid-thermal3d-load → main

An off-board MOSFET and finned heatsink currently have no resolved 3D temperature/heat-flow field in Fields. Add independent thermal3d --spec --out solid-conduction analysis with explicit materials, physical contacts, exposed-face convection and a dependency-free GLB surface exporter. Existing board analysis and live UI remain unchanged. Details and schema are in docs/thermal3d.md; both skill entry points describe when to use it.

Base: fbce2638ab91b76cf34f802c5a10a11cfeaaecc8 (0.7.15). Resolves the initial solid-conduction portion of issue #12; no dependency on pending packageCooling PR #11. Shared main.rs additions are limited to module/CLI dispatch registration. No deletions or replaced existing functions. Preflight passed and the actual branch comparison contains exactly six reviewed files.

Validation: cargo check --workspace --bins, cargo test --workspace (26 passed, including 7 new analytical/validation tests), cargo build --release. Existing vendor assets were supplied from the prior local build; no vendor source or shared runtime was changed. Three cooler concepts solved in 21 cases, with <0.5% ceiling change on the checked mesh refinement cases and energy balance checked. Native AdomLapper Pup canonical viewer loaded the exported GLB, correct four named bodies and numeric results; screenshot reviewed. Interactive project demonstration: https://wiki.adom.inc/adom/adom-custom-electronic-load/render/docs/cooling-volume/viewer.html

Limitations: axis-aligned box unions only; constant materials; supplied convection, not CFD; no radiation, transient capacity, heat-pipe transport, automatic GLB property inference, PCB coupling, or integration into existing serve/ui/SSE. Q1 silicon remains a separately bounded lumped junction-to-case estimate in the project. These are assumption-dependent screens, not qualified ratings. Production publication, installation and owner approval remain pending; no shared binary was replaced.

Diff Skip to comments (1)

SKILL.md+4
@@ -184,3 +184,7 @@ on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the chips sit over the fields. ++## 3D solid thermal screening (source development)++For a hot component mounted above the PCB, a heatsink, fins, or an insulating interface, read `docs/thermal3d.md`. The `thermal3d` command accepts explicit solid boxes, conductivities, physical contacts and convection, and exports temperatures and face heat flow. `tools/thermal3d_glb.py` exports named thermal surfaces for the canonical viewer. This independent mode does not infer thermal properties from a hero GLB and is not coupled to the board solver or its live UI. Run fan-off/on sensitivity and mesh convergence; keep material, airflow and interface assumptions visible. Never treat hot-swap pulse SOA, fan RPM, or a converged numerical solution as a continuous-power qualification.
docs/thermal3d.mdadded+41
@@ -0,0 +1,41 @@+# Thermal fields in solids beyond the PCB++`adom-fields thermal3d --spec assembly.json --out solved`++This independent mode solves steady 3D conduction through axis-aligned solid volumes, including heatsink fins, interface layers and package contact surfaces. It does not reuse PCB layer geometry or automatically assign materials to GLB meshes. Existing board `analyze`, `serve` and `ui` commands are unchanged. This mode is not yet integrated with the board viewer/SSE commands.++Example (millimetres, watts, degrees Celsius):++```json+{+  "name": "uniformly heated slab",+  "ambientC": 20,+  "pitchMm": 1,+  "assumptions": ["Only the top face exchanges heat with air"],+  "bodies": [{+    "name": "slab",+    "boxesMm": [[0,0,0,10,10,10]],+    "conductivityWMk": 10,+    "convectionWM2k": [0,0,0,0,0,100],+    "watts": 1,+    "provenance": "Analytical test fixture, not a product rating"+  }],+  "contacts": []+}+```++A body's boxes form a union; different bodies may touch but cannot overlap. Every touching pair of different bodies requires one contact declaration, e.g. `{"bodies":["case","isolator"],"resistanceM2kW":0.00002,"provenance":"Measured interface at specified pressure"}`. Zero resistance explicitly means ideal contact. A declared contact with no shared face is rejected. Contact resistance is area-normalized, not K/W; a uniform patch has R=R''/area.++`convectionWM2k` orders faces -X,+X,-Y,+Y,-Z,+Z. It applies only to exposed faces; zero insulates. Air is not meshed. Each source's watts are distributed uniformly over its union volume. To model a localized die, create its explicit body/contact rather than heating an entire heatsink. A lumped junction-to-case estimate may be added separately to a suitable package case boundary; never count the same resistance twice.++The grid includes every box boundary and subdivides intervals to at most `pitchMm`, preserving thin layers instead of rounding them away. Neighbour conductance includes both half-cell material resistances and any interface resistance. Boundary convection includes half-cell conduction. The solver rejects unsupported fields, negative/nonfinite inputs, overlaps, missing contacts, insulated islands, excessive grids and failed convergence/energy balance. Limit: two million Cartesian grid cells, including empty cells.++`thermal3d.json` contains the input and its SHA-256, cell bounds and temperatures, signed heat transfer and flux on conductive faces, exposed-face heat rejection, body extrema, PCG residual and energy balance. Temperature is cell-centred, not an exact surface measurement. Check mesh convergence near thin contacts and local sources. Float colours are for visualization; numerical extrema remain in JSON.++Export a Z-up, metre-unit GLB with named bodies and solved cell colours:++```+python3 tools/thermal3d_glb.py solved/thermal3d.json solved/thermal3d.glb --color-min 20 --color-max 100+```++Use the canonical Adom 3D viewer with Z-up enabled. All scenarios must use the same colour limits for comparison. Mesh faces at material interfaces are retained so hiding a heatsink reveals the underlying contact layer. Export is piecewise constant per cell, with no invented smooth temperature gradient. Above/below colour limits saturates; include the numeric maximum and range in the viewer. No automatic GLB material/thermal-property inference, arbitrary triangle mesh import, fan animation, CFD, radiation, phase change, transient capacity, or heat-pipe transport is implemented. Prescribed moving-air h is not measured installed fan performance. Thermal ceilings from these assumptions are conditional estimates, never qualified device ratings.
skills/adom-fields/SKILL.md+4
@@ -176,3 +176,7 @@ Each solve records SHA-256 identities of the board and spec in `fields.json.inpu Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.  For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.++## 3D solid thermal screening (source development)++For a hot component mounted above the PCB, a heatsink, fins, or an insulating interface, read `docs/thermal3d.md`. The `thermal3d` command accepts explicit solid boxes, conductivities, physical contacts and convection, and exports temperatures and face heat flow. `tools/thermal3d_glb.py` exports named thermal surfaces for the canonical viewer. This independent mode does not infer thermal properties from a hero GLB and is not coupled to the board solver or its live UI. Run fan-off/on sensitivity and mesh convergence; keep material, airflow and interface assumptions visible. Never treat hot-swap pulse SOA, fan RPM, or a converged numerical solution as a continuous-power qualification.
src/main.rs+4
@@ -1,5 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};+mod thermal3d; mod caps; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value};@@ -11,6 +12,8 @@ struct Cli { #[command(subcommand)] cmd: Cmd }  #[derive(Subcommand)] enum Cmd {+    /// Solve axis-aligned 3D solids with explicit contacts and prescribed convection (not CFD)+    Thermal3d { #[arg(long)] spec: String, #[arg(long)] out: PathBuf },     /// Solve the fields of a board: current density per loaded net, heat flow in the copper, temperature over the board; writes fields.json and the textures to --out     Analyze { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long, default_value = "fields")] out: PathBuf, /// solve cell in mm (0.2 is a minute on a 65 x 75 mm board; 0.1 is four times longer)     #[arg(long, default_value_t = 0.2)] pitch: f64, /// the current density a loaded net may not exceed before it is an issue, A/mm² (60 is about IPC-2221 external at 35 µm for 10 °C)@@ -155,6 +158,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck fn main() {     let cli = Cli::parse();     match cli.cmd {+        Cmd::Thermal3d { spec, out } => match thermal3d::run(&spec, &out) { Ok(()) => ok("3D solid thermal solve written", &[]), Err(e) => err(&e, &[]) },         Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir, no_silk } => {             match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }, no_silk) {                 Ok(s) => {
src/thermal3d.rsadded+434
@@ -0,0 +1,434 @@+//! Steady 3D solid conduction. Prescribed convection is NOT a CFD fan model.+use crate::solve::System;+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::{+    collections::{HashMap, HashSet},+    path::Path,+};++#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Body {+    pub name: String,+    /// Union of axis-aligned boxes [xmin,ymin,zmin,xmax,ymax,zmax], mm.+    pub boxes_mm: Vec<[f64; 6]>,+    pub conductivity_w_mk: f64,+    /// Exposed-face h, ordered -x,+x,-y,+y,-z,+z. Zero means insulated.+    pub convection_w_m2k: [f64; 6],+    pub watts: f64,+    pub provenance: String,+}+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Contact {+    pub bodies: [String; 2],+    pub resistance_m2k_w: f64,+    pub provenance: String,+}+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Spec {+    pub name: String,+    pub ambient_c: f64,+    pub pitch_mm: f64,+    pub bodies: Vec<Body>,+    pub contacts: Vec<Contact>,+    pub assumptions: Vec<String>,+}+#[derive(Clone, Serialize)]+#[serde(rename_all = "camelCase")]+struct Cell {+    body: usize,+    bounds_mm: [f64; 6],+    temperature_c: f64,+}+fn positive(x: f64) -> bool {+    x.is_finite() && x > 0.0+}+fn nonnegative(x: f64) -> bool {+    x.is_finite() && x >= 0.0+}+fn axis(mut edges: Vec<f64>, pitch: f64) -> Result<Vec<f64>, String> {+    edges.sort_by(f64::total_cmp);+    edges.dedup_by(|a, b| (*a - *b).abs() < 1e-8);+    let mut out = Vec::new();+    for p in edges.windows(2) {+        let n = ((p[1] - p[0]) / pitch).ceil() as usize;+        if n > 10000 || out.len() + n > 10000 {+            return Err("axis exceeds 10000 cells".into());+        }+        for i in 0..n {+            out.push(p[0] + (p[1] - p[0]) * i as f64 / n as f64);+        }+    }+    out.push(*edges.last().ok_or("empty geometry")?);+    Ok(out)+}+fn near_index(a: &[f64], x: f64) -> usize {+    a.iter().position(|v| (*v - x).abs() < 1e-7).unwrap()+}++pub fn solve(spec: &Spec) -> Result<Value, String> {+    if !spec.ambient_c.is_finite()+        || spec.ambient_c <= -273.15+        || !positive(spec.pitch_mm)+        || spec.bodies.is_empty()+        || spec.assumptions.is_empty()+    {+        return Err(+            "require ambient above absolute zero, positive pitch, bodies and assumptions".into(),+        );+    }+    let mut names = HashMap::new();+    let mut axes: [Vec<f64>; 3] = Default::default();+    for (i, b) in spec.bodies.iter().enumerate() {+        if b.name.trim().is_empty()+            || names.insert(b.name.clone(), i).is_some()+            || b.provenance.trim().is_empty()+            || b.boxes_mm.is_empty()+            || !positive(b.conductivity_w_mk)+            || !nonnegative(b.watts)+            || b.convection_w_m2k.iter().any(|h| !nonnegative(*h))+        {+            return Err("invalid or duplicate body; require material/geometry provenance".into());+        }+        for bb in &b.boxes_mm {+            for d in 0..3 {+                if !bb[d].is_finite() || !bb[d + 3].is_finite() || bb[d + 3] - bb[d] < 1e-7 {+                    return Err("invalid box bounds".into());+                }+                axes[d].extend([bb[d], bb[d + 3]]);+            }+        }+    }+    let mut contacts = HashMap::new();+    for c in &spec.contacts {+        let a = *names.get(&c.bodies[0]).ok_or("unknown contact body")?;+        let b = *names.get(&c.bodies[1]).ok_or("unknown contact body")?;+        if a == b+            || !nonnegative(c.resistance_m2k_w)+            || c.provenance.trim().is_empty()+            || contacts+                .insert((a.min(b), a.max(b)), c.resistance_m2k_w)+                .is_some()+        {+            return Err("invalid/duplicate contact".into());+        }+    }+    for a in &mut axes {+        *a = axis(std::mem::take(a), spec.pitch_mm)?;+    }+    let dims = [axes[0].len() - 1, axes[1].len() - 1, axes[2].len() - 1];+    let total = dims+        .iter()+        .try_fold(1usize, |a, b| a.checked_mul(*b))+        .ok_or("grid overflow")?;+    if total > 2_000_000 {+        return Err("grid exceeds 2 million cells; simplify geometry or increase pitch".into());+    }+    let flat = |p: [usize; 3]| p[0] + dims[0] * (p[1] + dims[1] * p[2]);+    let mut owner = vec![usize::MAX; total];+    for (bi, b) in spec.bodies.iter().enumerate() {+        for bb in &b.boxes_mm {+            let lo = [+                near_index(&axes[0], bb[0]),+                near_index(&axes[1], bb[1]),+                near_index(&axes[2], bb[2]),+            ];+            let hi = [+                near_index(&axes[0], bb[3]),+                near_index(&axes[1], bb[4]),+                near_index(&axes[2], bb[5]),+            ];+            for z in lo[2]..hi[2] {+                for y in lo[1]..hi[1] {+                    for x in lo[0]..hi[0] {+                        let o = &mut owner[flat([x, y, z])];+                        if *o != usize::MAX && *o != bi {+                            return Err(format!(+                                "overlapping bodies {} and {}",+                                spec.bodies[*o].name, b.name+                            ));+                        }+                        *o = bi;+                    }+                }+            }+        }+    }+    let mut map = vec![usize::MAX; total];+    let mut cells = Vec::new();+    let mut coords = Vec::new();+    let mut volumes = vec![0.0; spec.bodies.len()];+    let mut sys = System::new(0, 1, 1);+    for z in 0..dims[2] {+        for y in 0..dims[1] {+            for x in 0..dims[0] {+                let p = [x, y, z];+                let o = owner[flat(p)];+                if o == usize::MAX {+                    continue;+                }+                let bb = [+                    axes[0][x],+                    axes[1][y],+                    axes[2][z],+                    axes[0][x + 1],+                    axes[1][y + 1],+                    axes[2][z + 1],+                ];+                volumes[o] += (bb[3] - bb[0]) * (bb[4] - bb[1]) * (bb[5] - bb[2]);+                map[flat(p)] = sys.add_node(0.0);+                coords.push(p);+                cells.push(Cell {+                    body: o,+                    bounds_mm: bb,+                    temperature_c: 0.0,+                });+            }+        }+    }+    let mut used = HashSet::new();+    let mut exposed = Vec::new();+    let mut edge_meta = Vec::new();+    for (i, c) in cells.iter().enumerate() {+        let p = coords[i];+        let size = [+            c.bounds_mm[3] - c.bounds_mm[0],+            c.bounds_mm[4] - c.bounds_mm[1],+            c.bounds_mm[5] - c.bounds_mm[2],+        ];+        for d in 0..3 {+            for sign in 0..2 {+                let mut nb = p;+                let j = if (sign == 0 && p[d] == 0) || (sign == 1 && p[d] + 1 == dims[d]) {+                    usize::MAX+                } else {+                    if sign == 0 {+                        nb[d] -= 1;+                    } else {+                        nb[d] += 1;+                    }+                    map[flat(nb)]+                };+                let area = size[(d + 1) % 3] * size[(d + 2) % 3] * 1e-6;+                if j == usize::MAX {+                    let h = spec.bodies[c.body].convection_w_m2k[2 * d + sign];+                    // Includes half-cell conduction to the convecting face.+                    let g = if h > 0.0 {+                        area / (1.0 / h + size[d] * 0.0005 / spec.bodies[c.body].conductivity_w_mk)+                    } else {+                        0.0+                    };+                    sys.extra_leak[i] += g;+                    exposed.push((i, d, sign, area, g));+                } else if sign == 1 {+                    let other = &cells[j];+                    let key = (c.body.min(other.body), c.body.max(other.body));+                    let rc = if c.body == other.body {+                        0.0+                    } else {+                        used.insert(key);+                        *contacts.get(&key).ok_or_else(|| {+                            format!(+                                "touching bodies require explicit contact: {} / {}",+                                spec.bodies[c.body].name, spec.bodies[other.body].name+                            )+                        })?+                    };+                    let g = area+                        / (size[d] * 0.0005 / spec.bodies[c.body].conductivity_w_mk+                            + (other.bounds_mm[d + 3] - other.bounds_mm[d]) * 0.0005+                                / spec.bodies[other.body].conductivity_w_mk+                            + rc);+                    sys.edges.push((i, j, g));+                    edge_meta.push((d, area));+                }+            }+        }+    }+    if contacts.keys().any(|k| !used.contains(k)) {+        return Err("declared contact has no shared face; check placement".into());+    }+    // Every connected solid island must have a path to ambient, even unpowered islands.+    let mut adj = vec![Vec::new(); cells.len()];+    for &(a, b, _) in &sys.edges {+        adj[a].push(b);+        adj[b].push(a);+    }+    let mut seen = vec![false; cells.len()];+    for root in 0..cells.len() {+        if !seen[root] {+            let mut stack = vec![root];+            seen[root] = true;+            let mut sink = false;+            while let Some(a) = stack.pop() {+                sink |= sys.extra_leak[a] > 0.0;+                for &b in &adj[a] {+                    if !seen[b] {+                        seen[b] = true;+                        stack.push(b);+                    }+                }+            }+            if !sink {+                return Err("solid island has no thermal path to ambient".into());+            }+        }+    }+    let rhs: Vec<f64> = cells+        .iter()+        .map(|c| {+            let b = c.bounds_mm;+            spec.bodies[c.body].watts * (b[3] - b[0]) * (b[4] - b[1]) * (b[5] - b[2])+                / volumes[c.body]+        })+        .collect();+    let (rise, iterations, residual) = sys.solve(&rhs, 1e-10, 20000);+    let input: f64 = rhs.iter().sum();+    let rejected: f64 = rise.iter().zip(&sys.extra_leak).map(|(t, g)| t * g).sum();+    let balance = (input - rejected).abs() / input.max(1e-12);+    if !residual.is_finite()+        || residual > 1e-8+        || rise.iter().any(|v| !v.is_finite() || *v < -1e-6)+        || balance > 1e-6+    {+        return Err(format!(+            "failed convergence/energy balance: residual {residual}, balance {balance}"+        ));+    }+    for (c, t) in cells.iter_mut().zip(&rise) {+        c.temperature_c = spec.ambient_c + t;+    }+    let body_results:Vec<Value>=spec.bodies.iter().enumerate().map(|(i,b)|{+        let cs:Vec<&Cell>=cells.iter().filter(|c|c.body==i).collect();+        json!({"name":b.name,"watts":b.watts,"minC":cs.iter().map(|c|c.temperature_c).fold(f64::INFINITY,f64::min),"maxC":cs.iter().map(|c|c.temperature_c).fold(f64::NEG_INFINITY,f64::max),"volumeMm3":volumes[i]})+    }).collect();+    let faces:Vec<Value>=exposed.iter().map(|&(i,d,s,area,g)|json!({"cell":i,"axis":d,"positive":s==1,"areaM2":area,"wattsToAmbient":g*rise[i]})).collect();+    let flux:Vec<Value>=sys.edges.iter().zip(&edge_meta).map(|(&(a,b,g),&(d,area))|json!({"from":a,"to":b,"axis":d,"watts":g*(rise[a]-rise[b]),"fluxWm2":g*(rise[a]-rise[b])/area})).collect();+    Ok(+        json!({"schema":"adom-fields-thermal3d-v1","name":spec.name,"method":"Steady finite-volume solid conduction; prescribed convection; no CFD, radiation or heat-pipe transport","ambientC":spec.ambient_c,"pitchMm":spec.pitch_mm,"assumptions":spec.assumptions,"input":spec,"gridDimensions":dims,"cellCount":cells.len(),"iterations":iterations,"relativeResidual":residual,"inputWatts":input,"rejectedWatts":rejected,"relativeEnergyError":balance,"bodies":body_results,"cells":cells,"exposedFaces":faces,"conductiveFaces":flux}),+    )+}++pub fn run(path: &str, out: &Path) -> Result<(), String> {+    let bytes = std::fs::read(path).map_err(|e| e.to_string())?;+    let spec: Spec = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;+    let mut result = solve(&spec)?;+    result["inputSha256"] = json!(format!("{:x}", Sha256::digest(&bytes)));+    result["solverVersion"] = json!(env!("CARGO_PKG_VERSION"));+    if std::fs::read(path).map_err(|e| e.to_string())? != bytes {+        return Err("input changed during solve".into());+    }+    std::fs::create_dir_all(out).map_err(|e| e.to_string())?;+    std::fs::write(+        out.join("thermal3d.json"),+        serde_json::to_vec(&result).unwrap(),+    )+    .map_err(|e| e.to_string())?;+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;+    fn slab() -> Spec {+        Spec {+            name: "slab".into(),+            ambient_c: 20.0,+            pitch_mm: 1.0,+            bodies: vec![Body {+                name: "solid".into(),+                boxes_mm: vec![[0., 0., 0., 10., 10., 10.]],+                conductivity_w_mk: 10.,+                convection_w_m2k: [0., 0., 0., 0., 0., 100.],+                watts: 1.,+                provenance: "analytical fixture".into(),+            }],+            contacts: vec![],+            assumptions: vec!["uniform volumetric heat, only top convects".into()],+        }+    }+    #[test]+    fn slab_matches_analytic_and_balance() {+        let s = slab();+        let r = solve(&s).unwrap();+        let max = r["bodies"][0]["maxC"].as_f64().unwrap(); // T(bottom cell center) = Ta+Q/hA+q'''*(L²-z²)/2k+        let expected = 20. + 100. + 1000000. * (0.01f64.powi(2) - 0.0005f64.powi(2)) / 20.;+        assert!((max - expected).abs() < 0.03);+        assert!(r["relativeEnergyError"].as_f64().unwrap() < 1e-8);+    }+    #[test]+    fn insulated_island_rejected() {+        let mut s = slab();+        s.bodies[0].convection_w_m2k = [0.; 6];+        assert!(solve(&s).unwrap_err().contains("island"));+    }+    #[test]+    fn overlap_rejected() {+        let mut s = slab();+        let mut b = s.bodies[0].clone();+        b.name = "other".into();+        s.bodies.push(b);+        assert!(solve(&s).unwrap_err().contains("overlapping"));+    }+    #[test]+    fn contact_drop_and_missing_contact() {+        let mut s = slab();+        s.bodies[0].convection_w_m2k = [0.; 6];+        let mut b = s.bodies[0].clone();+        b.name = "sink".into();+        b.boxes_mm = vec![[0., 0., 10., 10., 10., 20.]];+        b.watts = 0.;+        b.convection_w_m2k[5] = 100.;+        s.bodies.push(b);+        assert!(solve(&s).unwrap_err().contains("explicit contact"));+        s.contacts.push(Contact {+            bodies: ["solid".into(), "sink".into()],+            resistance_m2k_w: 0.,+            provenance: "fixture".into(),+        });+        let a = solve(&s).unwrap();+        s.contacts[0].resistance_m2k_w = 0.001;+        let b = solve(&s).unwrap();+        assert!(+            (b["bodies"][0]["maxC"].as_f64().unwrap()+                - a["bodies"][0]["maxC"].as_f64().unwrap()+                - 10.)+                .abs()+                < 1e-5+        );+    }+    #[test]+    fn wrong_contact_rejected() {+        let mut s = slab();+        s.contacts.push(Contact {+            bodies: ["solid".into(), "missing".into()],+            resistance_m2k_w: 0.,+            provenance: "test".into(),+        });+        assert!(solve(&s).is_err());+    }+    #[test]+    fn zero_power_is_ambient() {+        let mut s = slab();+        s.bodies[0].watts = 0.;+        let r = solve(&s).unwrap();+        assert_eq!(r["bodies"][0]["maxC"], 20.);+    }+    #[test]+    fn refinement_converges() {+        let mut s = slab();+        s.pitch_mm = 2.;+        let a = solve(&s).unwrap();+        s.pitch_mm = 0.5;+        let b = solve(&s).unwrap();+        assert!(+            (a["bodies"][0]["maxC"].as_f64().unwrap() - b["bodies"][0]["maxC"].as_f64().unwrap())+                .abs()+                < 0.1+        );+    }+}
tools/thermal3d_glb.pyadded+43
@@ -0,0 +1,43 @@+#!/usr/bin/env python3+"""Export solved thermal surfaces to a GLB, using only Python's standard library.+Temperature is piecewise constant per cell. Axes are Z-up; GLB coordinates metres.+Color limits are explicit and shared across scenarios; no invented interior gradient.+"""+import argparse,json,struct+from pathlib import Path++def export(result,out,lo,hi):+    if hi<=lo:raise ValueError('color-max must exceed color-min')+    cells=result['cells']; groups=[[] for _ in result['bodies']]+    for f in result['exposedFaces']:groups[cells[f['cell']]['body']].append((f['cell'],f['axis'],f['positive']))+    for f in result['conductiveFaces']:+        a,b=f['from'],f['to']+        if cells[a]['body']!=cells[b]['body']:+            groups[cells[a]['body']].append((a,f['axis'],True));groups[cells[b]['body']].append((b,f['axis'],False))+    doc={'asset':{'version':'2.0','generator':'Adom Fields thermal3d'},'scene':0,'scenes':[{'nodes':[]}],'nodes':[],'meshes':[],'bufferViews':[],'accessors':[],'materials':[{'doubleSided':True,'pbrMetallicRoughness':{'metallicFactor':0,'roughnessFactor':1},'extensions':{'KHR_materials_unlit':{}}}],'extensionsUsed':['KHR_materials_unlit'],'extras':{'temperatureRangeC':[lo,hi],'coordinates':'metres, Z up','inputSha256':result.get('inputSha256'),'method':result['method']}}+    data=bytearray()+    def acc(values,kind):+        n=3;offset=len(data)+        for row in values:data.extend(struct.pack('<3f',*row))+        bv=len(doc['bufferViews']);doc['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(data)-offset})+        a={'bufferView':bv,'componentType':5126,'count':len(values),'type':'VEC3'}+        if kind=='position':a.update(min=[min(v[d] for v in values) for d in range(n)],max=[max(v[d] for v in values) for d in range(n)])+        idx=len(doc['accessors']);doc['accessors'].append(a);return idx+    for bi,faces in enumerate(groups):+        if not faces:continue+        positions=[];colors=[]+        for ci,d,positive in faces:+            c=cells[ci];bb=c['boundsMm'];u=(d+1)%3;v=(d+2)%3;pts=[]+            for x,y in [(0,0),(1,0),(1,1),(0,1)]:+                p=[0.,0.,0.];p[d]=bb[d+3] if positive else bb[d];p[u]=bb[u+3*x];p[v]=bb[v+3*y];pts.append([t/1000 for t in p])+            t=max(0,min(1,(c['temperatureC']-lo)/(hi-lo)))+            color=(min(1,2*t),min(1,2*(1-t)),1-t)+            order=(0,1,2,0,2,3) if positive else (0,2,1,0,3,2)+            for j in order:positions.append(pts[j]);colors.append(color)+        pa=acc(positions,'position');ca=acc(colors,'color');mi=len(doc['meshes'])+        name=result['bodies'][bi]['name'];doc['meshes'].append({'name':name,'primitives':[{'attributes':{'POSITION':pa,'COLOR_0':ca},'material':0}]});doc['nodes'].append({'name':name,'mesh':mi});doc['scenes'][0]['nodes'].append(mi)+    doc['buffers']=[{'byteLength':len(data)}];j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4)+    raw=struct.pack('<III',0x46546c67,2,12+8+len(j)+8+len(data))+struct.pack('<II',len(j),0x4e4f534a)+j+struct.pack('<II',len(data),0x004e4942)+data+    Path(out).write_bytes(raw)+if __name__=='__main__':+    p=argparse.ArgumentParser();p.add_argument('result');p.add_argument('out');p.add_argument('--color-min',type=float,required=True);p.add_argument('--color-max',type=float,required=True);a=p.parse_args();export(json.loads(Path(a.result).read_text()),a.out,a.color_min,a.color_max)

Comments

John Lauer ·

Merged into main as adom-fields 0.7.17: adom-fields thermal3d --spec --out, thermal3d.rs with its tests, docs/thermal3d.md and the GLB tool.

Log in to comment.