//! Import fra IFC (IFC2x3 og IFC4), som er formatet Vectorworks eksporterer med //! «Export IFC Project». //! //! Importen henter klimaskjermen: yttervegger, tak, gulv, vinduer og dører, med areal, //! U-verdi og himmelretning. Areal og U-verdi hentes fra mengder (`IfcElementQuantity`) og //! egenskapssett (`Pset_WallCommon.ThermalTransmittance` m.fl.) når modellen har dem, ellers //! av geometriske attributter. Himmelretningen utledes av elementets plassering: veggens //! lokale x-akse gir planet, og ytre normal velges som den som peker bort fra bygningens //! tyngdepunkt. Prosjektets sanne nord (`IfcGeometricRepresentationContext.TrueNorth`) //! legges til. use crate::inndata::ifcgeom; use crate::inndata::step::{self, Arg, Entitet, Fil}; use crate::inndata::toml_inn::Importvalg; use crate::klima::geo; use crate::modell::*; use crate::Result; use anyhow::Context; use std::collections::HashMap; use std::path::Path; pub struct Importrapport { pub bygning: Bygning, pub merknader: Vec, /// Bygningsdeler som ikke er tatt med i klimaskjermen: navn, areal og U-verdi. pub utelatt: Vec<(String, f64, Option)>, } /// Konstruksjonsnavn og avrundet plassering, som skiller én bygningsdel fra en annen. type Dublettnokkel = (String, i64, i64, i64, i64); const VEGGTYPER: [&str; 3] = ["IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL"]; const PLATETYPER: [&str; 3] = ["IFCSLAB", "IFCROOF", "IFCSLABSTANDARDCASE"]; pub fn les(sti: &Path) -> Result { les_med(sti, &Importvalg::default()) } pub fn les_med(sti: &Path, valg: &Importvalg) -> Result { let raa = std::fs::read(sti).with_context(|| format!("leser {}", sti.display()))?; let tekst = String::from_utf8_lossy(&raa); fra_tekst_med( &tekst, sti.file_stem() .and_then(|s| s.to_str()) .unwrap_or("Bygning"), valg, ) } pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result { fra_tekst_med(tekst, standardnavn, &Importvalg::default()) } pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Result { let fil = step::les(tekst); let mut merknader = Vec::new(); let skala = ifcgeom::lengdeenhet(&fil); let sant_nord = sant_nord(&fil); let egenskaper = samle_egenskaper(&fil); let mengder = samle_mengder(&fil); let (vindu_vert, dor_vert) = samle_apninger(&fil); let (etasjer, etasje_for) = les_etasjer(&fil, skala); // En eksport uten bygningsdeler gir ingen klimaskjerm å regne på. Det skjer typisk når // designlagene ikke er knyttet til etasjer i eksportdialogen, og da er det bedre å stoppe // enn å skrive en tom bygningsbeskrivelse. let antall_deler: usize = [ "IFCWALL", "IFCWALLSTANDARDCASE", "IFCCURTAINWALL", "IFCSLAB", "IFCROOF", "IFCWINDOW", "IFCDOOR", "IFCSPACE", ] .iter() .map(|t| fil.av_type(t).count()) .sum(); if antall_deler == 0 { anyhow::bail!( "IFC-filen har ingen bygningsdeler: ingen vegger, dekker, tak, vinduer, dører eller rom. \ Den inneholder bare prosjektstrukturen{}. I Vectorworks skjer dette gjerne når designlagene \ ikke er med under «Layer Mapping» i «Export IFC Project».", if etasjer.is_empty() { String::new() } else { format!(" med {} etasjer", etasjer.len()) } ); } // ── oppvarmet areal fra rommene ───────────────────────────────── // Rom i en etasje under terreng hører til kjelleren og ikke til oppvarmet BRA. let mut bra = 0.0; let mut volum = 0.0; let mut kjellerareal = 0.0; let mut kjellervolum = 0.0; let mut rom: Vec<(String, f64)> = Vec::new(); let mut kjellerrom: Vec<(String, f64)> = Vec::new(); let mut mistenkelige: Vec = Vec::new(); let mut uoppvarmede: Vec = Vec::new(); for (r, e) in fil.av_type("IFCSPACE") { let navn = romnavn(e); let areal = mengder.get(&r).map(arealmengde).unwrap_or(0.0); // Rom som beskriver bebygget areal eller bruttoareal er ikke gulvareal. let maalerom = matches!(navn.to_uppercase().as_str(), n if n.starts_with("BYA") || n.starts_with("BTA")); if areal <= 0.0 || maalerom { continue; } let romvolum = mengder .get(&r) .and_then(|m| m.get("NetVolume").or_else(|| m.get("GrossVolume")).copied()) .unwrap_or(0.0); let under = etasje_for .get(&r) .and_then(|i| etasjer.get(*i)) .map(|e| e.under_terreng) .unwrap_or(false); if under { kjellerrom.push((navn, areal)); kjellerareal += areal; kjellervolum += romvolum; } else { let lavt = navn.to_lowercase(); if valg .uoppvarmede_rom .iter() .any(|u| lavt.contains(&u.to_lowercase())) { uoppvarmede.push(format!("{navn} {areal:.1} m²")); continue; } if ["kott", "bod", "loft", "garasje", "carport", "uoppvarmet"] .iter() .any(|o| lavt.contains(o)) { mistenkelige.push(format!("{navn} {areal:.1} m²")); } rom.push((navn, areal)); bra += areal; volum += romvolum; } } rom.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); if !rom.is_empty() { let liste: Vec = rom.iter().map(|(n, a)| format!("{n} {a:.1}")).collect(); merknader.push(format!( "Oppvarmet BRA er satt til {bra:.1} m², summen av rommene i etasjene over terreng. Rom [m²]: {}.", liste.join(", ") )); } if !mistenkelige.is_empty() { merknader.push(format!( "Disse rommene er regnet som oppvarmet, men navnet tyder på at de ikke er det: {}. Trekk dem fra oppvarmet_bra om de står uoppvarmet.", mistenkelige.join(", ") )); } if !uoppvarmede.is_empty() { merknader.push(format!( "Disse rommene er uoppvarmet etter tilleggsfilen og holdt utenfor oppvarmet BRA: {}.", uoppvarmede.join(", ") )); } // ── plassering og geometri for alle bygningsdeler ─────────────── let typer: Vec<&str> = VEGGTYPER.iter().chain(PLATETYPER.iter()).copied().collect(); let mut deler: Vec = Vec::new(); for (r, e) in fil.av_typer(&typer) { let plass = e .arg(5) .and_then(|a| a.som_ref()) .and_then(|p| global_plassering(&fil, p, skala)); let netter = ifcgeom::netter_for_produkt(&fil, e.arg(6).and_then(|a| a.som_ref())); let flate = ifcgeom::samlet_hovedflate(&netter, skala); deler.push(Del { nr: r, typ: e.typ.clone(), navn: navn_av(e, r, standardnavn_for(&e.typ)), etasje: etasje_for.get(&r).copied(), ytre: er_ytre(&egenskaper, r), u_verdi: u_verdi(&egenskaper, r), plass, areal: flate.map(|f| f.areal).unwrap_or(0.0), normal: flate.map(|f| f.normal), z: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.hoyde).unwrap_or(0.0), topp: plass.map(|p| p.2).unwrap_or(0.0) + flate.map(|f| f.topp).unwrap_or(0.0), }); } // Noen eksporter legger den samme bygningsdelen i flere etasjer. To deler med samme // konstruksjon, samme areal og samme plassering er den samme veggen eller det samme // dekket. Kopiene blir stående, slik at vinduene i dem finner veggen sin, men de telles // ikke med i klimaskjermen. Av kopiene beholdes den som ligger i den etasjen høyden // tilsier, slik at en kjellervegg havner i kjelleren. let konstruksjon = |navn: &str| navn.split(" #").next().unwrap_or(navn).trim().to_string(); let mut grupper: Vec<(Dublettnokkel, Vec)> = Vec::new(); for d in &deler { let Some((x, y, _, _, _)) = d.plass else { continue; }; let cm = |v: f64| (v * 50.0).round() as i64; let nokkel = ( konstruksjon(&d.navn), cm(x), cm(y), cm(d.z), (d.areal * 100.0).round() as i64, ); match grupper.iter_mut().find(|(k, _)| *k == nokkel) { Some((_, medlemmer)) => medlemmer.push(d.nr), None => grupper.push((nokkel, vec![d.nr])), } } let mut dublett_av: HashMap = HashMap::new(); for (_, medlemmer) in grupper.iter().filter(|(_, m)| m.len() > 1) { let avstand_til_etasjen = |nr: u64| { let d = deler.iter().find(|d| d.nr == nr)?; let hoyde = d.etasje.and_then(|i| etasjer.get(i)).map(|e| e.hoyde)?; Some((d.z - hoyde).abs()) }; let beholdt = *medlemmer .iter() .min_by(|a, b| { let (fa, fb) = (avstand_til_etasjen(**a), avstand_til_etasjen(**b)); match (fa, fb) { (Some(x), Some(y)) => x.total_cmp(&y).then(a.cmp(b)), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a.cmp(b), } }) .unwrap_or(&medlemmer[0]); for nr in medlemmer.iter().filter(|nr| **nr != beholdt) { dublett_av.insert(*nr, beholdt); } } let dubletter = dublett_av.len(); // Tyngdepunkt av de ytre veggene bestemmer hvilken vei normalen peker. let ytre_deler: Vec<&Del> = deler .iter() .filter(|d| VEGGTYPER.contains(&d.typ.as_str()) && d.ytre.unwrap_or(false)) .collect(); let referanse: Vec<&Del> = if ytre_deler.is_empty() { deler .iter() .filter(|d| VEGGTYPER.contains(&d.typ.as_str())) .collect() } else { ytre_deler }; let (mut cx, mut cy, mut n) = (0.0, 0.0, 0.0); for d in &referanse { if let Some((x, y, _, _, _)) = d.plass { cx += x; cy += y; n += 1.0; } } if n > 0.0 { cx /= n; cy /= n; } // ── kjeller som egen sone ─────────────────────────────────────── // Finnes det en etasje under terreng med rom, er den nesten alltid uoppvarmet. // Bygningsdelene der hører til kjellerens klimaskjerm, og etasjeskilleren over blir // grensen mellom den oppvarmede delen og sonen. let kjelleretasje = (!kjellerrom.is_empty()) .then(|| etasjer.iter().position(|e| e.under_terreng)) .flatten(); let kjellernavn = kjelleretasje .and_then(|i| etasjer.get(i)) .map(|e| e.navn.clone()) .unwrap_or_default(); // Etasjehøyden gir hvor dypt kjelleren ligger. let kjellerdybde = kjelleretasje .and_then(|i| { let denne = etasjer.get(i)?.hoyde; let over = etasjer.get(i + 1).map(|e| e.hoyde).unwrap_or(denne + 2.4); Some(over - denne) }) .unwrap_or(0.0); // Den laveste etasjen over terreng, der etasjeskilleren mot kjelleren ligger. let forste_over = etasjer.iter().position(|e| !e.under_terreng); // Terrenghøyden på IfcBuilding sier hvor mye av grunnmuren som står over bakken. let terrengmodell = terrengpunkter(&fil, skala); let terreng = terrenghoyder(&fil, skala); // Ligger terrengmodellen over gulvet i første etasje, er den ikke lagt i bakkenivå, og da // sier den ingenting om hvor mye av grunnmuren som er nedgravd. let terreng_over_gulvet = match (terreng, forste_over.and_then(|i| etasjer.get(i))) { (Some((referanse, terrengnivaa)), Some(forste)) => { terrengnivaa > referanse + forste.hoyde + 0.5 } _ => false, }; let kjeller_over_terreng = match (terreng, forste_over.and_then(|i| etasjer.get(i))) { (Some((referanse, terrengnivaa)), Some(forste)) if !terreng_over_gulvet => { over_terreng_for_kjeller(referanse, terrengnivaa, forste.hoyde, kjellerdybde) } _ => 0.0, }; // ── klimaskjerm ───────────────────────────────────────────────── let mut kjellerflater: Vec = Vec::new(); let mut flater: Vec = Vec::new(); let mut utelatt: Vec<(String, f64, Option)> = Vec::new(); let mut uten_uverdi: Vec = Vec::new(); let mut uten_geometri = 0usize; let mut grunnmurshoyder: Vec = Vec::new(); let mut asimut_for_vegg: HashMap = HashMap::new(); // Laveste etasje og laveste gulvnivå skiller dekke mot grunn fra etasjeskillere. let etasjekontekst = Etasjekontekst { kjeller: kjelleretasje, forste_over, forste_over_hoyde: forste_over.and_then(|i| etasjer.get(i)).map(|e| e.hoyde), }; let laveste = deler .iter() .filter(|d| PLATETYPER.contains(&d.typ.as_str())) .map(|d| d.z) .fold(f64::INFINITY, f64::min); for d in &deler { let er_vegg = VEGGTYPER.contains(&d.typ.as_str()); let (asimut, helning) = retning_av(d, cx, cy, sant_nord); if er_vegg { asimut_for_vegg.insert(d.nr, asimut); } if d.areal <= 0.05 { uten_geometri += 1; continue; } if dublett_av.contains_key(&d.nr) { continue; } let i_kjeller = kjelleretasje.is_some() && d.etasje == kjelleretasje; let Some((typ, mot)) = klassifiser(d, laveste, helning, &etasjekontekst) else { utelatt.push((d.navn.clone(), d.areal, d.u_verdi)); continue; }; // En etasjeskiller like over kjelleren er grensen mot den uoppvarmede sonen. Høyden // avgjør, ikke etasjetilknytningen, som kan være feil i eksporten. let ved_kjellertaket = etasjekontekst .forste_over_hoyde .map(|h| (d.z - h).abs() < 0.6) .unwrap_or(false); let (mot, sone) = if kjelleretasje.is_some() && !i_kjeller && ved_kjellertaket && typ == FlateType::Gulv && mot != Mot::Grunn { (Mot::Uoppvarmet, Some(kjellernavn.clone())) } else { (mot, None) }; if d.u_verdi.is_none() { uten_uverdi.push(d.navn.clone()); } // Terrengmodellen sier hvor høyt bakken ligger akkurat der denne veggen står, så en // skrånende tomt gir forskjellig høyde over bakken på hver side av huset. // Høyden måles fra overkant mur, ikke fra gulvet over: dekket selv står ikke i bakken. let over_bakken = d .plass .and_then(|(x, y, _, _, _)| terreng_ved(&terrengmodell, x, y)) .map(|z| (d.topp - z).clamp(0.0, kjellerdybde)) .unwrap_or(kjeller_over_terreng); if i_kjeller && mot == Mot::Grunn && typ == FlateType::Vegg { grunnmurshoyder.push(over_bakken); } let mal = if i_kjeller { &mut kjellerflater } else { &mut flater }; mal.push(Flate { navn: d.navn.clone(), typ, areal: d.areal, orientering: if typ == FlateType::Vegg { asimut } else { 0.0 }, helning: Some(helning), u_verdi: d.u_verdi, lag: None, masseklasse: masseklasse_av_materialer(&fil, d.nr), masseplassering: Masseplassering::Fordelt, mot, b_faktor: None, absorptans: None, omkrets: None, dybde: (i_kjeller && mot == Mot::Grunn).then_some(kjellerdybde - over_bakken), over_terreng: (i_kjeller && mot == Mot::Grunn).then_some(over_bakken), sone, kommentar: d .etasje .and_then(|i| etasjer.get(i)) .map(|e| format!("{}, importert fra IFC", e.navn)) .or_else(|| Some("importert fra IFC".into())), }); } slaa_sammen(&mut flater); slaa_sammen(&mut kjellerflater); // ── vinduer og dører ──────────────────────────────────────────── let mut vinduer: Vec = Vec::new(); let mut sette_vinduer: std::collections::HashSet<(String, (i64, i64, i64), i64)> = Default::default(); let mut vindusdubletter = 0usize; for (r, e) in fil.av_type("IFCWINDOW").chain(fil.av_type("IFCDOOR")) { let dor = e.typ == "IFCDOOR"; let hoyde = e.arg(8).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala; let bredde = e.arg(9).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala; let areal = mengder .get(&r) .map(arealmengde) .filter(|a| *a > 0.05) .unwrap_or(hoyde * bredde); if areal <= 0.05 { continue; } let vert = if dor { dor_vert.get(&r) } else { vindu_vert.get(&r) } .map(|v| dublett_av.get(v).copied().unwrap_or(*v)); let vert = vert.as_ref(); // Vinduer i innervegger hører ikke til klimaskjermen. let i_ytre_vegg = vert .and_then(|v| deler.iter().find(|d| d.nr == *v)) .map(|d| d.ytre.unwrap_or(true)) .unwrap_or(true); if !i_ytre_vegg { utelatt.push(( format!("{} (i innervegg)", navn_av(e, r, "Vindu")), areal, u_verdi(&egenskaper, r), )); continue; } // Det samme vinduet i to kopier av den samme veggen er ett vindu. let sted = e .arg(5) .and_then(|a| a.som_ref()) .and_then(|p| global_plassering(&fil, p, skala)) .map(|(x, y, z, _, _)| { let cm = |v: f64| (v * 50.0).round() as i64; (cm(x), cm(y), cm(z)) }) .unwrap_or_default(); let nokkel = ( konstruksjon(&navn_av(e, r, if dor { "Dør" } else { "Vindu" })), sted, (areal * 100.0).round() as i64, ); if !sette_vinduer.insert(nokkel) { vindusdubletter += 1; continue; } let asimut = vert .and_then(|v| asimut_for_vegg.get(v)) .copied() .unwrap_or(0.0); // Uten U-verdi i modellen lar vi feltet stå tomt, slik at beregningen bruker en // typisk verdi for byggeåret i stedet for en fast standardverdi. let u = u_verdi(&egenskaper, r); if u.is_none() { uten_uverdi.push(navn_av(e, r, if dor { "Dør" } else { "Vindu" })); } vinduer.push(Vindu { navn: navn_av(e, r, if dor { "Dør" } else { "Vindu" }), areal, antall: 1.0, orientering: asimut, helning: 90.0, u_verdi: u, g_verdi: egenskap(&egenskaper, r, "SolarHeatGainTransmittance") .or_else(|| egenskap(&egenskaper, r, "SolarTransmittance")), karmandel: egenskap(&egenskaper, r, "GlazingAreaFraction").map(|g| 1.0 - g), solskjerming: None, horisont: None, dor, kommentar: Some("importert fra IFC".into()), }); } // Vindus- og dørarealet trekkes fra veggene de sitter i, dersom geometrien ikke // allerede har hull der åpningene er. let mut apning_per_vegg: HashMap = HashMap::new(); for (a, vegg) in vindu_vert.iter().chain(dor_vert.iter()) { let e = fil.hent(*a); let h = e .and_then(|e| e.arg(8)) .and_then(|x| x.som_tall()) .unwrap_or(0.0) * skala; let b = e .and_then(|e| e.arg(9)) .and_then(|x| x.som_tall()) .unwrap_or(0.0) * skala; *apning_per_vegg.entry(*vegg).or_default() += h * b; } let mut trukket_fra = 0usize; for d in &deler { let Some(ap) = apning_per_vegg.get(&d.nr) else { continue; }; let Some(f) = flater.iter_mut().find(|f| f.navn == d.navn) else { continue; }; // Ligger veggarealet nær bruttoarealet, har geometrien ikke hull, og åpningene trekkes fra. if f.areal > *ap * 1.5 && d.normal.is_some() { let netto = (f.areal - ap).max(0.0); if netto > 0.05 { f.areal = netto; trukket_fra += 1; } } } if trukket_fra > 0 { merknader.push(format!( "Vindus- og dørareal er trukket fra {trukket_fra} vegger. Kontroller veggarealene mot tegningen." )); } // Tilleggsfiler og scenarioer viser til flatene med navn, så navnene må være entydige. unike_navn(&mut flater); unike_navn(&mut kjellerflater); // ── merknader ─────────────────────────────────────────────────── if bra <= 0.0 { bra = flater .iter() .filter(|f| f.typ == FlateType::Gulv) .map(|f| f.areal) .sum::(); merknader.push(if bra > 0.0 { format!("IFC-modellen mangler romarealer; oppvarmet BRA er anslått til {bra:.0} m² av gulvflatene. Kontroller verdien.") } else { "IFC-modellen har verken romarealer eller gulvflater; fyll inn oppvarmet_bra.".into() }); } if uten_geometri > 0 { merknader.push(format!( "{uten_geometri} bygningsdeler er utelatt fordi geometrien ikke ga noe areal." )); } if !utelatt.is_empty() { // Gruppert per konstruksjon, slik at listen er til å lese. let mut grupper: Vec<(String, usize, f64)> = Vec::new(); for (navn, areal, _) in &utelatt { let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string(); match grupper.iter_mut().find(|(n, _, _)| *n == kort) { Some((_, antall, sum)) => { *antall += 1; *sum += areal; } None => grupper.push((kort, 1, *areal)), } } grupper.sort_by(|a, b| b.2.total_cmp(&a.2)); let liste: Vec = grupper .iter() .take(10) .map(|(navn, antall, sum)| { if *antall > 1 { format!("{navn} ×{antall} ({sum:.0} m²)") } else { format!("{navn} ({sum:.0} m²)") } }) .collect(); merknader.push(format!( "{} bygningsdeler er ikke regnet med i klimaskjermen, som innervegger og dekker inne i bygningen: {}{}.", utelatt.len(), liste.join(", "), if grupper.len() > liste.len() { " …" } else { "" } )); } if dubletter + vindusdubletter > 0 { merknader.push(format!( "{} bygningsdeler ligger dobbelt i modellen, samme konstruksjon på samme sted, og er telt én gang. Det skjer når eksporten legger de samme delene i flere etasjer.", dubletter + vindusdubletter )); } if !uten_uverdi.is_empty() { // Navnene gjør det tydelig hva som må rettes i modellen. Flere elementer av samme // konstruksjon nevnes én gang, med antallet. let mut antall: Vec<(String, usize)> = Vec::new(); for navn in &uten_uverdi { let kort = navn.split(" #").next().unwrap_or(navn).trim().to_string(); match antall.iter_mut().find(|(n, _)| *n == kort) { Some((_, a)) => *a += 1, None => antall.push((kort, 1)), } } let liste: Vec = antall .iter() .map(|(n, a)| { if *a > 1 { format!("{n} ×{a}") } else { n.clone() } }) .collect(); merknader.push(format!( "{} bygningsdeler mangler U-verdi i IFC-filen: {}. Legg inn U-verdier i modellen, eller la enok bruke typiske verdier for byggeåret.", uten_uverdi.len(), liste.join(", ") )); } merknader.push( "Arealene er regnet av geometrien: trekantene er gruppert etter retning, og den største gruppen er delens hovedflate. Himmelretningene følger flatenormalen. Kontroller begge mot tegningen." .into(), ); merknader.push( "IFC beskriver ikke drift, lekkasjetall eller energiforsyning. Fyll inn [ventilasjon], [infiltrasjon], [oppvarming], [tappevann] og [klima] før beregning." .into(), ); let (data, funnet) = les_byggdata(&fil, &egenskaper); if !funnet.is_empty() { merknader.push(format!( "Modellen har egenskaper for drift og forsyning, og disse er tatt i bruk: {}.", funnet.join(", ") )); } else { merknader.push( "Modellen har ingen egenskaper for byggeår, tetthet, ventilasjon eller varmekilde. \ Festes et postformat med slike felter på bygningen i BIM-verktøyet og knyttes til et \ IFC-egenskapssett, kommer de med hit. Se «Egenskaper enok leser» i enoks dokumentasjon." .into(), ); } let ubrukelig = |s: &str| { let s = s.trim(); s.is_empty() || s == "Default Building" || s == "Vectorworks Project" }; // Bygningens navn, ellers prosjektets langnavn eller navn, ellers filnavnet. let navn = fil .av_type("IFCBUILDING") .next() .and_then(|(_, e)| e.navn().map(|s| s.to_string())) .filter(|s| !ubrukelig(s)) .or_else(|| { fil.av_type("IFCPROJECT").next().and_then(|(_, p)| { [p.arg(5), p.arg(2)] .into_iter() .flatten() .filter_map(|a| a.som_tekst()) .find(|s| !ubrukelig(s)) .map(|s| s.trim().to_string()) }) }) .unwrap_or_else(|| standardnavn.to_string()); // Vernede bygninger er unntatt plikten til å ha energiattest. let vernet = fil .av_type("IFCBUILDING") .next() .and_then(|(r, _)| egenskaper.get(&r)) .and_then(|e| e.get("IsLandmarked")) .map(|a| matches!(a.som_tekst(), Some("T") | Some("TRUE") | Some("true"))) .unwrap_or(false); if vernet { merknader.push( "Bygningen er merket som vernet i modellen. Vernede bygninger etter kulturminneloven og \ plan- og bygningsloven er unntatt plikten til å ha energiattest, men beregningen kan \ fortsatt brukes til å vurdere tiltak." .into(), ); } // Kjellersonen settes sammen av flatene i etasjen under terreng. let mut soner: Vec = Vec::new(); if let Some(i) = kjelleretasje { if !kjellerflater.is_empty() { let volum = if kjellervolum > 0.0 { kjellervolum } else { kjellerareal * kjellerdybde.max(2.0) }; let rom: Vec = kjellerrom .iter() .map(|(n, a)| format!("{n} {a:.1}")) .collect(); soner.push(Sone { navn: kjellernavn.clone(), volum: (volum > 0.0).then_some(volum), luftveksling: None, flater: kjellerflater, andel_distribusjonstap: None, andel_tappevannstap: None, fast_effekt: 0.0, kommentar: Some(format!( "Etasje «{}» ligger {:.2} m under referansenivået. Rom [m²]: {}", etasjer[i].navn, -etasjer[i].hoyde, rom.join(", ") )), }); let grunnmur = if !grunnmurshoyder.is_empty() && !terrengmodell.is_empty() { let laveste = grunnmurshoyder .iter() .copied() .fold(f64::INFINITY, f64::min); let hoyeste = grunnmurshoyder .iter() .copied() .fold(f64::NEG_INFINITY, f64::max); let snitt = grunnmurshoyder.iter().sum::() / grunnmurshoyder.len() as f64; format!( "Terrengmodellen i filen gir hver kjellervegg sin egen høyde over bakken: \ {laveste:.2}–{hoyeste:.2} m, i snitt {snitt:.2} m. Resten av veggen er regnet nedgravd." ) } else if terreng_over_gulvet { format!( "Terrengmodellen ligger over gulvet i første etasje, så den er ikke brukt. \ Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Legg \ terrengmodellen i bakkenivå, eller sett `over_terreng` i meter på veggene." ) } else if terreng.is_some() { format!( "Etter terrenghøyden i modellen står grunnmuren {kjeller_over_terreng:.2} m over bakken og \ {:.2} m under.", kjellerdybde - kjeller_over_terreng ) } else { format!( "Kjellerveggene er regnet {kjellerdybde:.1} m under terreng uten noe over. Står en del av \ grunnmuren over bakken, sett `over_terreng` i meter på veggene, eller sett terrenghøyden \ på bygningen i modellen. Utslaget er lite, om lag én prosent per meter." ) }; merknader.push(format!( "Etasjen «{}» ligger under terreng og er satt opp som en uoppvarmet sone med {:.0} m² gulv. \ {grunnmur}", etasjer[i].navn, kjellerareal )); merknader.push("Er kjelleren oppvarmet likevel, slett [[sone]] og legg flatene inn i klimaskjermen. \ Går det uisolerte varmerør der, sett `andel_distribusjonstap = 1.0` på sonen.".to_string()); } } let klimaposisjon = match posisjon(&fil) { Some(p) if p.er_standardplassering() => { merknader.push( "Modellen står på Vectorworks' standardplassering i Washington, så posisjonen er ikke \ brukt. Georefereres tegningen eller settes prosjektstedet, velger beregningen \ klimasted ut fra den." .into(), ); None } Some(p) if !p.i_norge() => { merknader.push(format!( "Modellen oppgir posisjon {:.4}° nord, {:.4}° øst, som ligger utenfor Norge. \ Posisjonen er ikke brukt til å velge klimasted.", p.bredde, p.lengde )); None } Some(p) => { merknader.push(format!( "Modellen oppgir posisjon {:.4}° nord, {:.4}° øst. Uten kommune velger beregningen \ klimastedet nærmest denne.", p.bredde, p.lengde )); Some([p.bredde, p.lengde]) } None => { merknader.push( "Modellen oppgir ingen posisjon. Georefereres tegningen eller settes prosjektstedet, \ velger beregningen klimasted ut fra den." .into(), ); None } }; vinduer.sort_by(|a, b| a.navn.cmp(&b.navn)); let bygning = Bygning { bygning: BygningInfo { navn, kategori: data .kategori .as_deref() .and_then(kategori_av_tekst) .unwrap_or(Kategori::Smaahus), byggeaar: data .byggeaar .unwrap_or(crate::modell::kontroll::IMPORTERT_BYGGEAAR), oppvarmet_bra: data.oppvarmet_bra.unwrap_or(bra), oppvarmet_volum: (volum > 0.0).then_some(volum), antall_boenheter: 1, bygningens_oppvarmede_bra: None, adresse: adresse(&fil), gnr_bnr: None, beskrivelse: Some("Importert fra IFC".into()), energiraadgiver: forfatter(&fil), oppdragsgiver: None, }, klima: Klima { kommune: data.kommune.clone(), posisjon: klimaposisjon, ..Klima::default() }, flate: flater, sone: soner, grunn: Grunn::default(), vindu: vinduer, kuldebroer: Kuldebroer::default(), infiltrasjon: Infiltrasjon { n50: data.n50, ..Infiltrasjon::default() }, ventilasjon: Ventilasjon { typ: data .ventilasjon .as_deref() .and_then(ventilasjon_av_tekst) .unwrap_or_default(), gjenvinning: data .gjenvinning .map(|v| if v > 1.0 { v / 100.0 } else { v }), ..Ventilasjon::default() }, tappevann: Tappevann::default(), oppvarming: Oppvarming { kilde: data .varmekilde .as_deref() .and_then(varmekilde_av_tekst) .map(|t| { vec![Varmekilde { typ: t, andel_rom: 1.0, andel_ventilasjon: None, andel_tappevann: 1.0, spf: None, turtemperatur: None, lukket: true, kommentar: Some("fra egenskap i IFC-modellen".into()), }] }) .unwrap_or_default(), ..Oppvarming::default() }, distribusjon: Distribusjon::default(), solcelle: None, kjoling: Kjoling::default(), lufting: Lufting::default(), drift: Drift::default(), scenario: Vec::new(), rapport: Rapport::default(), }; Ok(Importrapport { bygning, merknader, utelatt, }) } /// Slår sammen flater med samme konstruksjon, type og himmelretning til én oppføring. /// /// En BIM-modell deler gjerne den samme veggen i mange elementer, ett per rom eller etasje. /// For en energiberegning er det konstruksjonen og himmelretningen som teller, så oppføringene /// summeres. Himmelretningen rundes til nærmeste 45 grader før sammenligningen. fn slaa_sammen(flater: &mut Vec) { let nokkel = |f: &Flate| { let navn = f.navn.split(" #").next().unwrap_or(&f.navn).to_string(); let sektor = ((f.orientering / 45.0).round() as i64).rem_euclid(8); let u = (f.u_verdi.unwrap_or(-1.0) * 100.0).round() as i64; (navn, f.typ, f.mot, sektor, u) }; let mut samlet: Vec = Vec::new(); for f in flater.drain(..) { match samlet.iter_mut().find(|g| nokkel(g) == nokkel(&f)) { Some(g) => { // Arealvektet himmelretning og helning, slik at summen peker riktig vei og ikke // avhenger av rekkefølgen elementene kommer i. let sum = g.areal + f.areal; g.orientering = (g.orientering * g.areal + f.orientering * f.areal) / sum; if let (Some(a), Some(b)) = (g.helning, f.helning) { g.helning = Some((a * g.areal + b * f.areal) / sum); } g.areal = sum; } None => samlet.push(f), } } for f in &mut samlet { f.navn = f .navn .split(" #") .next() .unwrap_or(&f.navn) .trim() .to_string(); f.orientering = (f.orientering / 45.0).round() * 45.0; } *flater = samlet; } /// Gjør flatenavnene entydige. Vegger får himmelretningen etter konstruksjonsnavnet, og andre /// flater som fortsatt deler navn, nummereres. fn unike_navn(flater: &mut [Flate]) { // Fast rekkefølge, slik at samme modell alltid gir samme fil og samme nummerering. flater.sort_by(|a, b| { (a.typ as u8, &a.navn) .cmp(&(b.typ as u8, &b.navn)) .then(a.orientering.total_cmp(&b.orientering)) .then(b.areal.total_cmp(&a.areal)) }); for f in flater.iter_mut().filter(|f| f.typ == FlateType::Vegg) { f.navn = format!("{}, {}", f.navn, himmelretning(f.orientering)); } let mut antall: HashMap = HashMap::new(); for f in flater.iter_mut() { let n = antall.entry(f.navn.clone()).or_insert(0); *n += 1; if *n > 1 { f.navn = format!("{} ({n})", f.navn); } } } fn himmelretning(grader: f64) -> &'static str { const RETNINGER: [&str; 8] = [ "nord", "nordøst", "øst", "sørøst", "sør", "sørvest", "vest", "nordvest", ]; RETNINGER[((grader / 45.0).round() as i64).rem_euclid(8) as usize] } /// En etasje i modellen, med navn og høyde over byggets referansenivå. #[derive(Clone, Debug)] pub struct Etasje { pub navn: String, /// Høyde over byggets referansenivå, m. Negativ for kjeller. pub hoyde: f64, /// Ligger etasjen under terreng, slik navn eller høyde tyder på? pub under_terreng: bool, } /// Leser etasjene og hvilken etasje hver bygningsdel og hvert rom hører til. /// /// Etasjeinndelingen er den sikreste opplysningen en BIM-modell har om hva som er hva. /// En etasje som heter «Kjeller» og ligger under referansenivået er nesten alltid /// uoppvarmet, og bygningsdelene der hører til kjellerens klimaskjerm og ikke husets. fn les_etasjer(fil: &Fil, skala: f64) -> (Vec, HashMap) { let mut etasjer: Vec<(u64, Etasje)> = fil .av_type("IFCBUILDINGSTOREY") .map(|(r, e)| { let navn = e .navn() .filter(|s| !s.is_empty()) .unwrap_or("etasje") .to_string(); let hoyde = e.arg(9).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala; let lavt = navn.to_lowercase(); let under_terreng = hoyde < -0.5 || ["kjeller", "basement", "souterreng", "underetasje"] .iter() .any(|o| lavt.contains(o)); ( r, Etasje { navn, hoyde, under_terreng, }, ) }) .collect(); etasjer.sort_by(|a, b| { a.1.hoyde .partial_cmp(&b.1.hoyde) .unwrap_or(std::cmp::Ordering::Equal) }); let indeks: HashMap = etasjer .iter() .enumerate() .map(|(i, (r, _))| (*r, i)) .collect(); let mut for_element: HashMap = HashMap::new(); // Bygningsdeler knyttes til etasjen med IfcRelContainedInSpatialStructure. for (_, rel) in fil.av_type("IFCRELCONTAINEDINSPATIALSTRUCTURE") { let Some(etasje) = rel .arg(5) .and_then(|a| a.som_ref()) .and_then(|r| indeks.get(&r)) else { continue; }; for e in rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]) { if let Some(r) = e.som_ref() { for_element.insert(r, *etasje); } } } // Rom knyttes til etasjen med IfcRelAggregates. for (_, rel) in fil.av_type("IFCRELAGGREGATES") { let Some(etasje) = rel .arg(4) .and_then(|a| a.som_ref()) .and_then(|r| indeks.get(&r)) else { continue; }; for e in rel.arg(5).map(|a| a.som_liste()).unwrap_or(&[]) { if let Some(r) = e.som_ref() { for_element.insert(r, *etasje); } } } (etasjer.into_iter().map(|(_, e)| e).collect(), for_element) } /// Masseklasse ut fra materialnavnene i konstruksjonen. /// /// IFC-filer fra Vectorworks oppgir materialene som andeler uten tykkelse, så U-verdien /// kan ikke regnes av dem. Navnene sier likevel nok om massen: betong og tegl er tungt, /// bindingsverk og isolasjon er lett. fn masseklasse_av_materialer(fil: &Fil, element: u64) -> Option { let mut navn: Vec = Vec::new(); for (_, rel) in fil.av_type("IFCRELASSOCIATESMATERIAL") { let treff = rel .arg(4) .map(|a| a.som_liste()) .unwrap_or(&[]) .iter() .any(|o| o.som_ref() == Some(element)); if !treff { continue; } let Some(m) = rel .arg(5) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r)) else { continue; }; samle_materialnavn(fil, m, &mut navn, 0); } if navn.is_empty() { return None; } let tekst = navn.join(" ").to_lowercase(); let tungt = [ "concrete", "betong", "brick", "tegl", "masonry", "mur", "stone", "stein", "screed", ]; let lett = [ "insulat", "isolasjon", "timber", "wood", "tre", "panel", "gypsum", "gips", "air", ]; let t = tungt.iter().filter(|o| tekst.contains(**o)).count(); let l = lett.iter().filter(|o| tekst.contains(**o)).count(); Some(match (t, l) { (0, 0) => return None, (t, l) if t > l => Masseklasse::Tung, (t, l) if t == l && t > 0 => Masseklasse::Middels, _ => Masseklasse::Lett, }) } fn samle_materialnavn(fil: &Fil, e: &Entitet, ut: &mut Vec, dybde: u32) { if dybde > 4 { return; } if e.typ == "IFCMATERIAL" { if let Some(n) = e.arg(0).and_then(|a| a.som_tekst()) { ut.push(n.to_string()); } return; } for a in &e.args { match a { Arg::Ref(r) => { if let Some(n) = fil.hent(*r) { samle_materialnavn(fil, n, ut, dybde + 1); } } Arg::Liste(l) => { for x in l { if let Some(n) = x.som_ref().and_then(|r| fil.hent(r)) { samle_materialnavn(fil, n, ut, dybde + 1); } } } _ => {} } } } /// Opplysninger om drift og forsyning som IFC ikke har noen fast plass til. /// /// IFC beskriver geometri og konstruksjon, men ikke byggeår, tetthet, ventilasjon eller /// hvordan huset varmes opp. Det er nettopp de opplysningene som skiller en god /// energiberegning fra en middelmådig. De kan likevel følge med modellen: i Vectorworks /// lager man et postformat, fester det på bygningen og knytter det til et IFC-egenskapssett. /// Feltnavnene under leses fra et hvilket som helst egenskapssett på `IfcBuilding`, /// `IfcSite` eller `IfcProject`, på norsk eller engelsk. #[derive(Default, Debug)] pub struct Byggdata { pub byggeaar: Option, pub kategori: Option, pub kommune: Option, pub n50: Option, pub ventilasjon: Option, pub gjenvinning: Option, pub varmekilde: Option, pub oppvarmet_bra: Option, } /// Leser `Byggdata` fra egenskapssettene på bygningen, tomta eller prosjektet. fn les_byggdata(fil: &Fil, egenskaper: &Egenskaper) -> (Byggdata, Vec) { let mut d = Byggdata::default(); let mut funnet = Vec::new(); let baerere: Vec = ["IFCBUILDING", "IFCSITE", "IFCPROJECT"] .iter() .flat_map(|t| fil.av_type(t).map(|(r, _)| r).collect::>()) .collect(); let mut hent = |navn: &[&str]| -> Option { for b in &baerere { let Some(sett) = egenskaper.get(b) else { continue; }; for n in navn { if let Some((nokkel, v)) = sett.iter().find(|(k, _)| { k.eq_ignore_ascii_case(n) || k.replace(' ', "").eq_ignore_ascii_case(n) }) { funnet.push(nokkel.clone()); return Some(v.clone()); } } } None }; let tall = |a: Option| -> Option { let a = a?; a.som_tall().or_else(|| { a.som_tekst() .and_then(|s| s.replace(',', ".").trim().parse().ok()) }) }; let tekst = |a: Option| -> Option { a.and_then(|x| x.som_tekst().map(|s| s.trim().to_string())) .filter(|s| !s.is_empty()) }; d.byggeaar = tall(hent(&[ "Byggeaar", "Byggeår", "YearOfConstruction", "ConstructionYear", ])) .map(|v| v as i32); d.kategori = tekst(hent(&["Bygningskategori", "Kategori", "BuildingCategory"])); d.kommune = tekst(hent(&["Kommune", "Municipality", "Klimasted"])); d.n50 = tall(hent(&[ "Lekkasjetall", "n50", "AirPermeability", "AirTightness", ])); d.ventilasjon = tekst(hent(&[ "Ventilasjonstype", "Ventilasjon", "VentilationType", ])); d.gjenvinning = tall(hent(&[ "Varmegjenvinning", "Gjenvinning", "HeatRecoveryEfficiency", "TemperaturVirkningsgrad", ])); d.varmekilde = tekst(hent(&[ "Varmekilde", "Oppvarming", "HeatingSource", "HeatSource", ])); d.oppvarmet_bra = tall(hent(&["OppvarmetBRA", "Oppvarmet BRA", "HeatedFloorArea"])); (d, funnet) } /// Byggets posisjon. /// /// Georeferering gjennom `IfcMapConversion` og `IfcProjectedCRS` brukes først, siden den er /// mest presis. Ellers brukes breddegrad og lengdegrad på `IfcSite`. Vectorworks fyller begge /// med en standardplassering i Washington når ingenting er satt, og den velges bare når ingen /// annen posisjon finnes, slik at den kan kjennes igjen og oversees. fn posisjon(fil: &Fil) -> Option { let fra_kart = fil.av_type("IFCMAPCONVERSION").next().and_then(|(_, m)| { let crs = m .arg(1) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r))?; let navn = crs.arg(0).and_then(|a| a.som_tekst())?; let x = m.arg(2).and_then(|a| a.som_tall())?; let y = m.arg(3).and_then(|a| a.som_tall())?; geo::fra_koordinatsystem(navn, x, y) }); let fra_tomt = fil.av_type("IFCSITE").next().and_then(|(_, e)| { let grad = |a: Option<&Arg>| -> Option { let l = a?.som_liste(); if l.is_empty() { return None; } let g = |i: usize| l.get(i).and_then(|x| x.som_tall()).unwrap_or(0.0); let verdi = g(0).abs() + g(1).abs() / 60.0 + g(2).abs() / 3600.0 + g(3).abs() / 3.6e9; Some(if g(0) < 0.0 { -verdi } else { verdi }) }; Some(geo::Posisjon { bredde: grad(e.arg(9))?, lengde: grad(e.arg(10))?, }) }); [fra_kart, fra_tomt] .into_iter() .flatten() .find(|p| !p.er_standardplassering()) .or(fra_kart) .or(fra_tomt) } /// Referansehøyden og terrenghøyden på bygningen, begge over havet, m. fn terrenghoyder(fil: &Fil, skala: f64) -> Option<(f64, f64)> { let (_, e) = fil.av_type("IFCBUILDING").next()?; let referanse = e.arg(9).and_then(|a| a.som_tall())? * skala; let terreng = e.arg(10).and_then(|a| a.som_tall())? * skala; Some((referanse, terreng)) } /// Punktene i terrengmodellen, i byggets koordinater, m. /// /// Vectorworks legger terrenget som et `IfcGeographicElement` med typen TERRAIN, eller på /// tomta. Punktene brukes til å finne bakkenivået ved hver enkelt kjellervegg. fn terrengpunkter(fil: &Fil, skala: f64) -> Vec<[f64; 3]> { let mut ut = Vec::new(); for (_, e) in fil .av_type("IFCGEOGRAPHICELEMENT") .chain(fil.av_type("IFCSITE")) { let (px, py, pz) = e .arg(5) .and_then(|a| a.som_ref()) .and_then(|p| global_plassering(fil, p, skala)) .map(|(x, y, z, _, _)| (x, y, z)) .unwrap_or((0.0, 0.0, 0.0)); for nett in ifcgeom::netter_for_produkt(fil, e.arg(6).and_then(|a| a.som_ref())) { for p in &nett.punkter { ut.push([p[0] * skala + px, p[1] * skala + py, p[2] * skala + pz]); } } } ut } /// Bakkenivået ved et punkt, som snittet av de nærmeste punktene i terrengmodellen, m. fn terreng_ved(punkter: &[[f64; 3]], x: f64, y: f64) -> Option { if punkter.is_empty() { return None; } let mut avstand: Vec<(f64, f64)> = punkter .iter() .map(|p| ((p[0] - x).powi(2) + (p[1] - y).powi(2), p[2])) .collect(); avstand.sort_by(|a, b| a.0.total_cmp(&b.0)); let antall = avstand.len().min(8); Some(avstand[..antall].iter().map(|(_, z)| z).sum::() / antall as f64) } /// Hvor mye av grunnmuren som står over bakken, m. /// /// Toppen av grunnmuren ligger ved gulvet i første etasje over kjelleren. Den står over /// terrenget med differansen mellom etasjens høyde over havet og terrengets, begrenset til /// kjellerens høyde. fn over_terreng_for_kjeller( referanse: f64, terreng: f64, forste_etasje: f64, kjellerdybde: f64, ) -> f64 { ((referanse + forste_etasje) - terreng).clamp(0.0, kjellerdybde.max(0.0)) } /// En bygningsdel slik den er lest av IFC-filen. struct Del { nr: u64, typ: String, navn: String, etasje: Option, ytre: Option, u_verdi: Option, plass: Option<(f64, f64, f64, f64, f64)>, areal: f64, normal: Option<[f64; 3]>, /// Delens høyde over byggets nullpunkt, av geometrien, m. z: f64, /// Overkanten av delen over byggets nullpunkt, m. For en kjellervegg er det overkant mur. topp: f64, } /// Oversetter en fritekstverdi fra modellen til en bygningskategori. fn kategori_av_tekst(s: &str) -> Option { let t = s.to_lowercase(); Some(match t.as_str() { x if x.contains("småhus") || x.contains("smaahus") || x.contains("enebolig") => { Kategori::Smaahus } x if x.contains("boligblokk") || x.contains("leilighet") => Kategori::Boligblokk, x if x.contains("kontor") => Kategori::Kontorbygg, x if x.contains("skole") => Kategori::Skolebygg, x if x.contains("barnehage") => Kategori::Barnehage, x if x.contains("hotell") => Kategori::Hotell, x if x.contains("sykehus") => Kategori::Sykehus, x if x.contains("sykehjem") => Kategori::Sykehjem, x if x.contains("idrett") => Kategori::Idrettsbygg, x if x.contains("forretning") || x.contains("butikk") => Kategori::Forretningsbygg, x if x.contains("kultur") => Kategori::Kulturbygg, x if x.contains("industri") || x.contains("verksted") => Kategori::Industribygg, _ => return None, }) } fn ventilasjon_av_tekst(s: &str) -> Option { let t = s.to_lowercase(); Some(match t.as_str() { x if x.contains("balansert") || x.contains("balanced") || x.contains("mekanisk til") => { VentilasjonType::Balansert } x if x.contains("avtrekk") || x.contains("exhaust") => VentilasjonType::Avtrekk, x if x.contains("naturlig") || x.contains("natural") => VentilasjonType::Naturlig, _ => return None, }) } fn varmekilde_av_tekst(s: &str) -> Option { let t = s.to_lowercase(); Some(match t.as_str() { x if x.contains("luft-luft") || x.contains("luft/luft") || x.contains("air-air") => { VarmekildeType::LuftLuftVp } x if x.contains("luft-vann") || x.contains("luft/vann") => VarmekildeType::LuftVannVp, x if x.contains("berg") || x.contains("væske") || x.contains("vaeske") || x.contains("ground") => { VarmekildeType::VaeskeVannVp } x if x.contains("avtrekksvarmepumpe") => VarmekildeType::AvtrekkVp, x if x.contains("vedovn") || x.contains("ved") || x.contains("wood") => { VarmekildeType::Vedovn } x if x.contains("pellet") => VarmekildeType::Pellets, x if x.contains("fjernvarme") || x.contains("district") => VarmekildeType::Fjernvarme, x if x.contains("olje") => VarmekildeType::Olje, x if x.contains("gass") || x.contains("gas") => VarmekildeType::Gass, x if x.contains("elkjel") || x.contains("elektrokjel") || x.contains("kjel") => { VarmekildeType::ElKjel } x if x.contains("elektr") || x.contains("panelovn") || x.contains("electric") => { VarmekildeType::DirekteEl } _ => return None, }) } fn standardnavn_for(typ: &str) -> &'static str { if typ == "IFCROOF" { "Tak" } else if PLATETYPER.contains(&typ) { "Dekke" } else { "Vegg" } } fn romnavn(e: &Entitet) -> String { e.arg(7) .and_then(|a| a.som_tekst()) .filter(|s| !s.is_empty()) .or_else(|| e.arg(2).and_then(|a| a.som_tekst())) .unwrap_or("rom") .to_string() } /// Arealet fra en mengdesamling. Godtar både standardnavnene og verktøyspesifikke navn. fn arealmengde(m: &HashMap) -> f64 { for navn in [ "NetFloorArea", "GrossFloorArea", "NetArea", "GrossArea", "Area", ] { if let Some(v) = m.get(navn) { return *v; } } // Ellers: et hvilket som helst areal, slik at verktøyspesifikke navn som // «GSA BIM Area» også virker. m.iter() .filter(|(k, _)| k.to_lowercase().contains("area")) .map(|(_, v)| *v) .fold(0.0, f64::max) } /// Himmelretning og helning ut fra hovedflatens normal. fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) { let Some(n) = d.normal else { return ( 0.0, if PLATETYPER.contains(&d.typ.as_str()) { 0.0 } else { 90.0 }, ); }; // Normalen er gitt i lokale koordinater; roter den med delens plassering. let (dx, dy) = d.plass.map(|p| (p.3, p.4)).unwrap_or((1.0, 0.0)); let mut g = [n[0] * dx - n[1] * dy, n[0] * dy + n[1] * dx, n[2]]; let vannrett = (g[0] * g[0] + g[1] * g[1]).sqrt(); // Et skrått tak har også en vannrett komponent, men skal alltid ha normalen oppover; // ellers kan undersiden bli hovedflaten og gi helning 180° minus takvinkelen. let skraa_plate = PLATETYPER.contains(&d.typ.as_str()) && g[2].abs() > 0.1; if vannrett > 0.2 && !skraa_plate { // Veggflate: velg den normalen som peker bort fra bygningens tyngdepunkt. if let Some((x, y, _, _, _)) = d.plass { if g[0] * (x - cx) + g[1] * (y - cy) < 0.0 { g = [-g[0], -g[1], -g[2]]; } } } else if g[2] < 0.0 { // Vannrett flate: normalen oppover gir helning 0, nedover 180. g = [-g[0], -g[1], -g[2]]; } let helning = g[2].clamp(-1.0, 1.0).acos().to_degrees(); let asimut = (g[0].atan2(g[1]).to_degrees() + sant_nord).rem_euclid(360.0); (asimut, helning) } /// Avgjør om en bygningsdel hører til klimaskjermen, og hva den vender mot. /// Etasjeinndelingen som klassifiseringen trenger. struct Etasjekontekst { /// Etasjen under terreng, dersom modellen har en. kjeller: Option, /// Laveste etasje over terreng, der etasjeskilleren mot kjelleren ligger. forste_over: Option, /// Høyden på den etasjen, m. Geometrien veier tyngre enn etasjetilknytningen, som kan /// være feil i eksporten. forste_over_hoyde: Option, } fn klassifiser( d: &Del, laveste: f64, helning: f64, e: &Etasjekontekst, ) -> Option<(FlateType, Mot)> { let navn = d.navn.to_lowercase(); let grunnord = [ "kjellermur", "grunnmur", "ringmur", "såle", "sale", "fundament", ]; let takord = ["tak", "roof", "loft"]; if PLATETYPER.contains(&d.typ.as_str()) { if d.typ == "IFCROOF" || takord.iter().any(|o| navn.contains(o)) { return Some(( FlateType::Tak, if navn.contains("loft") { Mot::Uoppvarmet } else { Mot::Ute }, )); } // Et utkraget dekke eller balkonggulv er merket som ytre og vender mot det fri. if d.ytre == Some(true) { return Some((FlateType::Gulv, Mot::Ute)); } // Høyden av geometrien avgjør, siden etasjetilknytningen i en eksport kan være feil. // Et dekke som ligger på bakken sier det i navnet. Ellers er et dekke like under // gulvet i første etasje himlingen over kjelleren, og det nederste dekket ligger mot // grunnen. «Kjellergulv» er ikke nok til å si at dekket ligger på bakken: dekket over // kjelleren bærer ofte det samme navnet. let paa_grunnen = ["on grade", "på grunn", "paa grunn", "såle", "sale"]; if paa_grunnen.iter().any(|o| navn.contains(o)) { return Some((FlateType::Gulv, Mot::Grunn)); } if e.kjeller.is_some() { let ved_forste_over = e .forste_over_hoyde .map(|h| (d.z - h).abs() < 0.6) .unwrap_or(false); if ved_forste_over || (d.etasje.is_some() && d.etasje == e.forste_over) { return Some((FlateType::Gulv, Mot::Uoppvarmet)); } } if (d.z - laveste).abs() < 0.5 { return Some((FlateType::Gulv, Mot::Grunn)); } // Dekker lenger opp er etasjeskillere inne i bygningen. return None; } // Vegger. if d.ytre == Some(true) { return Some((FlateType::Vegg, Mot::Ute)); } if grunnord.iter().any(|o| navn.contains(o)) { return Some((FlateType::Vegg, Mot::Grunn)); } if d.ytre.is_none() && helning > 60.0 { // Uten `IsExternal` må vi anta at veggen er ytre. return Some((FlateType::Vegg, Mot::Ute)); } None } fn navn_av(e: &Entitet, r: u64, standard: &str) -> String { match e.navn() { Some(n) if !n.trim().is_empty() => format!("{n} #{r}"), _ => format!("{standard} #{r}"), } } /// Byggets adresse fra `IfcBuilding.BuildingAddress`, eller fra tomta. /// /// Adresser knyttet til personer og organisasjoner er forfatterens, ikke byggets, og brukes /// ikke. Plassholdere uten gate eller poststed, som Vectorworks setter inn, hoppes over. fn adresse(fil: &Fil) -> Option { let bygning = fil .av_type("IFCBUILDING") .filter_map(|(_, e)| e.arg(11).and_then(|a| a.som_ref())); let tomt = fil .av_type("IFCSITE") .filter_map(|(_, e)| e.arg(13).and_then(|a| a.som_ref())); bygning .chain(tomt) .filter_map(|r| fil.hent(r)) .filter(|e| e.typ == "IFCPOSTALADDRESS") .find_map(formater_adresse) } /// `IfcPostalAddress` har feltene Purpose, Description, UserDefinedPurpose, InternalLocation, /// AddressLines, PostalBox, Town, Region, PostalCode og Country. fn formater_adresse(e: &Entitet) -> Option { let linjer: Vec = e .arg(4) .map(|a| a.som_liste()) .unwrap_or(&[]) .iter() .filter_map(|a| a.som_tekst()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); let postnummer = e.arg(8).and_then(|a| a.som_tekst()).unwrap_or("").trim(); let sted = e.arg(6).and_then(|a| a.som_tekst()).unwrap_or("").trim(); if linjer.is_empty() && sted.is_empty() { return None; } let poststed: Vec<&str> = [postnummer, sted] .into_iter() .filter(|s| !s.is_empty()) .collect(); Some( linjer .into_iter() .chain((!poststed.is_empty()).then(|| poststed.join(" "))) .collect::>() .join(", "), ) } /// Forfatteren av modellen, som person og organisasjon. /// /// I en modell laget for energirådgivning er det energirådgiveren. Vectorworks' egen /// organisasjon, som står når ingenting er fylt inn, tas ikke med. fn forfatter(fil: &Fil) -> Option { let (_, po) = fil.av_type("IFCPERSONANDORGANIZATION").next()?; let person = po .arg(0) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r)); let organisasjon = po .arg(1) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r)); // IfcPerson: Identification, FamilyName, GivenName, … let personnavn = person .map(|p| { [p.arg(2), p.arg(1)] .into_iter() .flatten() .filter_map(|a| a.som_tekst()) .map(str::trim) .filter(|s| !s.is_empty()) .collect::>() .join(" ") }) .filter(|s| !s.is_empty()); // IfcOrganization: Identification, Name, … let organisasjonsnavn = organisasjon .and_then(|o| o.arg(1).and_then(|a| a.som_tekst())) .map(str::trim) .filter(|s| !s.is_empty() && !s.to_lowercase().contains("vectorworks")) .map(str::to_string); match (personnavn, organisasjonsnavn) { (Some(p), Some(o)) => Some(format!("{p}, {o}")), (Some(p), None) => Some(p), (None, Some(o)) => Some(o), (None, None) => None, } } /// Prosjektets dreining mot sant nord, i grader med klokka. fn sant_nord(fil: &Fil) -> f64 { for (_, e) in fil.av_type("IFCGEOMETRICREPRESENTATIONCONTEXT") { if let Some(r) = e.arg(5).and_then(|a| a.som_ref()) { if let Some(d) = fil.hent(r) { let v = d.arg(0).map(|a| a.som_liste()).unwrap_or(&[]); if v.len() >= 2 { let (x, y) = ( v[0].som_tall().unwrap_or(0.0), v[1].som_tall().unwrap_or(1.0), ); // TrueNorth peker mot nord i prosjektets koordinater. return x.atan2(y).to_degrees(); } } } } 0.0 } /// Sammensatt global plassering: (x, y, z, dx, dy) der (dx, dy) er lokal x-akse i planet. fn global_plassering(fil: &Fil, r: u64, skala: f64) -> Option<(f64, f64, f64, f64, f64)> { let mut x = 0.0; let mut y = 0.0; let mut z = 0.0; let mut dx = 1.0; let mut dy = 0.0; let mut kjede = Vec::new(); let mut gjeldende = Some(r); let mut vakt = 0; while let Some(p) = gjeldende { vakt += 1; if vakt > 64 { break; } let e = fil.hent(p)?; if e.typ != "IFCLOCALPLACEMENT" { break; } kjede.push(p); gjeldende = e.arg(0).and_then(|a| a.som_ref()); } for p in kjede.into_iter().rev() { let e = fil.hent(p)?; let ax = e .arg(1) .and_then(|a| a.som_ref()) .and_then(|a| fil.hent(a))?; let (lx, ly, lz) = punkt(fil, ax.arg(0).and_then(|a| a.som_ref()), skala); let (rx, ry) = retning(fil, ax.arg(2).and_then(|a| a.som_ref())).unwrap_or((1.0, 0.0)); // Roter det lokale punktet med foreldrenes akse og legg det til. let nx = x + lx * dx - ly * dy; let ny = y + lx * dy + ly * dx; x = nx; y = ny; z += lz; let ndx = dx * rx - dy * ry; let ndy = dy * rx + dx * ry; let lengde = (ndx * ndx + ndy * ndy).sqrt().max(1e-12); dx = ndx / lengde; dy = ndy / lengde; } Some((x, y, z, dx, dy)) } fn punkt(fil: &Fil, r: Option, skala: f64) -> (f64, f64, f64) { let Some(e) = r.and_then(|r| fil.hent(r)) else { return (0.0, 0.0, 0.0); }; let v = e.arg(0).map(|a| a.som_liste()).unwrap_or(&[]); let g = |i: usize| v.get(i).and_then(|a| a.som_tall()).unwrap_or(0.0) * skala; (g(0), g(1), g(2)) } fn retning(fil: &Fil, r: Option) -> Option<(f64, f64)> { let e = r.and_then(|r| fil.hent(r))?; let v = e.arg(0).map(|a| a.som_liste()).unwrap_or(&[]); if v.len() < 2 { return None; } Some(( v[0].som_tall().unwrap_or(1.0), v[1].som_tall().unwrap_or(0.0), )) } type Egenskaper = HashMap>; /// Samler alle egenskaper fra `IfcRelDefinesByProperties` per objekt. fn samle_egenskaper(fil: &Fil) -> Egenskaper { let mut ut: Egenskaper = HashMap::new(); for (_, rel) in fil.av_type("IFCRELDEFINESBYPROPERTIES") { let objekter = rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]); let Some(def) = rel .arg(5) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r)) else { continue; }; if def.typ != "IFCPROPERTYSET" { continue; } let mut verdier = HashMap::new(); for p in def.arg(4).map(|a| a.som_liste()).unwrap_or(&[]) { let Some(pe) = p.som_ref().and_then(|r| fil.hent(r)) else { continue; }; if pe.typ != "IFCPROPERTYSINGLEVALUE" { continue; } let (Some(navn), Some(v)) = (pe.arg(0).and_then(|a| a.som_tekst()), pe.arg(2)) else { continue; }; verdier.insert(navn.to_string(), v.clone()); } for o in objekter { if let Some(r) = o.som_ref() { ut.entry(r).or_default().extend(verdier.clone()); } } } // Typeobjektenes egenskaper arves av forekomstene. let mut arv: Vec<(u64, HashMap)> = Vec::new(); for (_, rel) in fil.av_type("IFCRELDEFINESBYTYPE") { let Some(typ) = rel.arg(5).and_then(|a| a.som_ref()) else { continue; }; let Some(verdier) = ut.get(&typ).cloned() else { continue; }; for o in rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]) { if let Some(r) = o.som_ref() { arv.push((r, verdier.clone())); } } } for (r, verdier) in arv { let e = ut.entry(r).or_default(); for (k, v) in verdier { e.entry(k).or_insert(v); } } ut } /// Samler alle mengder fra `IfcElementQuantity` per objekt. fn samle_mengder(fil: &Fil) -> HashMap> { let mut ut: HashMap> = HashMap::new(); for (_, rel) in fil.av_type("IFCRELDEFINESBYPROPERTIES") { let objekter = rel.arg(4).map(|a| a.som_liste()).unwrap_or(&[]); let Some(def) = rel .arg(5) .and_then(|a| a.som_ref()) .and_then(|r| fil.hent(r)) else { continue; }; if def.typ != "IFCELEMENTQUANTITY" { continue; } let mut verdier = HashMap::new(); for q in def.arg(5).map(|a| a.som_liste()).unwrap_or(&[]) { let Some(qe) = q.som_ref().and_then(|r| fil.hent(r)) else { continue; }; let Some(navn) = qe.arg(0).and_then(|a| a.som_tekst()) else { continue; }; if let Some(v) = qe.arg(3).and_then(|a| a.som_tall()) { verdier.insert(navn.to_string(), v); } } for o in objekter { if let Some(r) = o.som_ref() { ut.entry(r).or_default().extend(verdier.clone()); } } } ut } /// Kobler vinduer og dører til veggen de sitter i, via åpningene. fn samle_apninger(fil: &Fil) -> (HashMap, HashMap) { // åpning → vertselement let mut vert_for_apning: HashMap = HashMap::new(); for (_, rel) in fil.av_type("IFCRELVOIDSELEMENT") { if let (Some(vert), Some(apning)) = ( rel.arg(4).and_then(|a| a.som_ref()), rel.arg(5).and_then(|a| a.som_ref()), ) { vert_for_apning.insert(apning, vert); } } let mut vindu = HashMap::new(); let mut dor = HashMap::new(); for (_, rel) in fil.av_type("IFCRELFILLSELEMENT") { let (Some(apning), Some(element)) = ( rel.arg(4).and_then(|a| a.som_ref()), rel.arg(5).and_then(|a| a.som_ref()), ) else { continue; }; let Some(vert) = vert_for_apning.get(&apning) else { continue; }; match fil.hent(element).map(|e| e.typ.as_str()) { Some("IFCDOOR") => { dor.insert(element, *vert); } Some("IFCWINDOW") => { vindu.insert(element, *vert); } _ => {} } } (vindu, dor) } fn egenskap(e: &Egenskaper, r: u64, navn: &str) -> Option { e.get(&r)?.get(navn)?.som_tall() } fn u_verdi(e: &Egenskaper, r: u64) -> Option { [ "ThermalTransmittance", "UValue", "U-Value", "Varmegjennomgangskoeffisient", ] .iter() .find_map(|n| egenskap(e, r, n)) .filter(|u| *u > 0.0 && *u < 20.0) } fn er_ytre(e: &Egenskaper, r: u64) -> Option { let v = e.get(&r)?.get("IsExternal")?; match v { Arg::Enum(s) => Some(s == "T" || s.eq_ignore_ascii_case("true")), Arg::Tall(n) => Some(*n != 0.0), Arg::Tekst(s) => Some(s == "T" || s.eq_ignore_ascii_case("true")), _ => None, } } #[cfg(test)] mod tester { use super::*; /// To yttervegger på 5 × 2,5 m med hver sin trekantgeometri, i millimeter. /// Sørveggen ligger ved y = −5 m, nordveggen ved y = +5 m. fn prove() -> String { prove_med(&[(100, "Sorvegg", -5.0), (120, "Nordvegg", 5.0)]) } /// Som `prove`, men med de veggene testen ber om: (entitetsnummer, navn, y i meter). fn prove_med(vegger: &[(u64, &str, f64)]) -> String { let mut s = String::from( "ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n #2= IFCUNITASSIGNMENT((#1));\n #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);\n #4= IFCDIRECTION((0.,1.));\n #40= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#50,#4);\n #5= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\n #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);\n #20= IFCLOCALPLACEMENT($,#21);\n #21= IFCAXIS2PLACEMENT3D(#22,$,$);\n #22= IFCCARTESIANPOINT((0.,0.,0.));\n #200= IFCPROPERTYSINGLEVALUE('ThermalTransmittance',$,IFCREAL(0.18),$);\n #201= IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$);\n #202= IFCPROPERTYSET('ps',$,'Pset_WallCommon',$,(#200,#201));\n #300= IFCSPACE('s',$,'Stue',$,$,#20,$,'Stue',.ELEMENT.,.INTERNAL.,$);\n #310= IFCQUANTITYAREA('NetFloorArea',$,$,100.0,$);\n #311= IFCQUANTITYVOLUME('NetVolume',$,$,250.0,$);\n #312= IFCELEMENTQUANTITY('q2',$,'BaseQuantities',$,$,(#310,#311));\n #313= IFCRELDEFINESBYPROPERTIES('r3',$,$,$,(#300),#312);\n", ); // En vegg som en tynn kasse: 5 m lang, 2,5 m høy, 0,2 m tykk, i millimeter. let mut vegg = |nr: u64, navn: &str, y: f64| { let (p, l, h, tj) = (nr, 5000.0, 2500.0, 200.0); s.push_str(&format!( "#{}= IFCWALL('w{p}',$,'{navn}',$,$,#{},#{},$,$);\n", p, p + 1, p + 4 )); s.push_str(&format!("#{}= IFCLOCALPLACEMENT(#20,#{});\n", p + 1, p + 2)); s.push_str(&format!( "#{}= IFCAXIS2PLACEMENT3D(#{},$,#{});\n", p + 2, p + 3, p + 9 )); s.push_str(&format!( "#{}= IFCCARTESIANPOINT((0.,{:.1},0.));\n", p + 3, y * 1000.0 )); s.push_str(&format!( "#{}= IFCPRODUCTDEFINITIONSHAPE($,$,(#{}));\n", p + 4, p + 5 )); s.push_str(&format!( "#{}= IFCSHAPEREPRESENTATION(#40,'Body','Tessellation',(#{}));\n", p + 5, p + 6 )); s.push_str(&format!( "#{}= IFCTRIANGULATEDFACESET(#{},$,.T.,((1,2,3),(1,3,4),(5,7,6),(5,8,7),(1,5,6),(1,6,2),(2,6,7),(2,7,3),(3,7,8),(3,8,4),(4,8,5),(4,5,1)),$);\n", p + 6, p + 7 )); s.push_str(&format!( "#{}= IFCCARTESIANPOINTLIST3D(((0.,0.,0.),({l:.1},0.,0.),({l:.1},{tj:.1},0.),(0.,{tj:.1},0.),(0.,0.,{h:.1}),({l:.1},0.,{h:.1}),({l:.1},{tj:.1},{h:.1}),(0.,{tj:.1},{h:.1})));\n", p + 7 )); s.push_str(&format!("#{}= IFCDIRECTION((1.,0.,0.));\n", p + 9)); s.push_str(&format!( "#{}= IFCRELDEFINESBYPROPERTIES('rel{p}',$,$,$,(#{p}),#202);\n", p + 8 )); }; for (nr, navn, y) in vegger { vegg(*nr, navn, *y); } s.push_str("ENDSEC;\nEND-ISO-10303-21;"); s } #[test] fn leser_areal_uverdi_og_himmelretning_av_geometrien() { let r = fra_tekst(&prove(), "prove").unwrap(); let b = &r.bygning; assert_eq!(b.bygning.navn, "Testhus"); assert_eq!(b.bygning.oppvarmet_bra, 100.0); assert_eq!(b.bygning.oppvarmet_volum, Some(250.0)); assert_eq!(b.flate.len(), 2, "flater: {:?}", b.flate); for f in &b.flate { // Hovedflaten er 5 × 2,5 m, ikke summen av alle sider. assert!( (f.areal - 12.5).abs() < 0.01, "{} fikk areal {}", f.navn, f.areal ); assert_eq!(f.u_verdi, Some(0.18)); assert_eq!(f.mot, Mot::Ute); } let sor = b .flate .iter() .find(|f| f.navn.starts_with("Sorvegg")) .unwrap(); let nord = b .flate .iter() .find(|f| f.navn.starts_with("Nordvegg")) .unwrap(); assert!( (sor.orientering - 180.0).abs() < 1.0, "sør = {}", sor.orientering ); assert!( nord.orientering < 1.0 || nord.orientering > 359.0, "nord = {}", nord.orientering ); } #[test] fn samme_vegg_i_flere_etasjer_telles_en_gang() { // Den samme veggen på nøyaktig samme sted to ganger, slik en eksport gjør når den // legger de samme delene i flere etasjer. let tekst = prove_med(&[ (100, "Sorvegg", -5.0), (120, "Nordvegg", 5.0), (140, "Sorvegg", -5.0), ]); let r = fra_tekst(&tekst, "prove").unwrap(); assert_eq!(r.bygning.flate.len(), 2, "flater: {:?}", r.bygning.flate); assert!( r.merknader.iter().any(|m| m.contains("dobbelt i modellen")), "{:?}", r.merknader ); } #[test] fn vegger_faar_himmelretning_i_navnet() { let r = fra_tekst(&prove(), "prove").unwrap(); let mut navn: Vec<&str> = r.bygning.flate.iter().map(|f| f.navn.as_str()).collect(); navn.sort(); assert_eq!(navn, vec!["Nordvegg, nord", "Sorvegg, sør"]); } #[test] fn uoppvarmede_rom_fra_tilleggsfilen_holdes_utenfor_bra() { let valg = Importvalg { uoppvarmede_rom: vec!["STUE".into()], }; let r = fra_tekst_med(&prove(), "prove", &valg).unwrap(); assert_ne!(r.bygning.bygning.oppvarmet_bra, 100.0); assert!(r .merknader .iter() .any(|m| m.contains("uoppvarmet etter tilleggsfilen"))); } /// Lengdeenheten skal hentes gjennom prosjektets enhetsoppsett, ikke ved å lete /// gjennom alle enheter i filen. Prøvefilen har både millimeter og meter. #[test] fn lengdeenheten_hentes_fra_prosjektet() { let fil = step::les(&prove()); assert_eq!(ifcgeom::lengdeenhet(&fil), 0.001); } /// Et postformat festet på bygningen og eksportert som IFC-egenskapssett skal leses, /// slik at byggeår, tetthet, ventilasjon og varmekilde kommer med fra modellen. #[test] fn egenskapssett_paa_bygningen_leses() { let t = prove().replace( "#300= IFCSPACE(", "#400= IFCPROPERTYSINGLEVALUE('Byggeaar',$,IFCINTEGER(1948),$);\n\ #401= IFCPROPERTYSINGLEVALUE('Lekkasjetall',$,IFCREAL(8.0),$);\n\ #402= IFCPROPERTYSINGLEVALUE('Ventilasjonstype',$,IFCLABEL('Balansert'),$);\n\ #403= IFCPROPERTYSINGLEVALUE('Varmegjenvinning',$,IFCREAL(80.0),$);\n\ #404= IFCPROPERTYSINGLEVALUE('Varmekilde',$,IFCLABEL('Bergvarmepumpe'),$);\n\ #405= IFCPROPERTYSINGLEVALUE('Kommune',$,IFCLABEL('Indre \u{d8}stfold'),$);\n\ #406= IFCPROPERTYSET('ps2',$,'Enok',$,(#400,#401,#402,#403,#404,#405));\n\ #407= IFCRELDEFINESBYPROPERTIES('r4',$,$,$,(#10),#406);\n\ #300= IFCSPACE(", ); let r = fra_tekst(&t, "prove").unwrap(); let b = &r.bygning; assert_eq!(b.bygning.byggeaar, 1948); assert_eq!(b.infiltrasjon.n50, Some(8.0)); assert_eq!(b.ventilasjon.typ, VentilasjonType::Balansert); assert_eq!(b.ventilasjon.gjenvinning, Some(0.8)); assert_eq!(b.klima.kommune.as_deref(), Some("Indre Østfold")); assert_eq!(b.oppvarming.kilde.len(), 1); assert_eq!(b.oppvarming.kilde[0].typ, VarmekildeType::VaeskeVannVp); assert!(r.merknader.iter().any(|m| m.contains("tatt i bruk"))); } #[test] fn eksport_uten_bygningsdeler_gir_tydelig_feil() { let tom = "ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n\ #1= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\n\ #2= IFCUNITASSIGNMENT((#1));\n\ #3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,$,#2);\n\ #10= IFCBUILDING('b',$,'Hus',$,$,$,$,$,.ELEMENT.,$,$,$);\n\ #11= IFCBUILDINGSTOREY('k',$,'Kjeller',$,$,$,$,$,.ELEMENT.,-2350.);\n\ ENDSEC;\nEND-ISO-10303-21;"; let feil = fra_tekst(tom, "tom") .err() .expect("en tom eksport skal gi feil"); let tekst = feil.to_string(); assert!(tekst.contains("ingen bygningsdeler"), "{tekst}"); assert!(tekst.contains("1 etasjer"), "{tekst}"); } /// Adressen på firmaet som laget modellen skal ikke bli byggets adresse. #[test] fn adressen_hentes_fra_bygningen_og_ikke_fra_forfatteren() { let t = prove().replace( "#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);", "#501= IFCPOSTALADDRESS($,$,$,$,('Firmaveien 1'),$,'Firmaby',$,'9999','Norge');\n\ #502= IFCORGANIZATION('999999999','Eksempel AS',$,$,(#501));\n\ #503= IFCPERSON($,'Nordmann','Kari',$,$,$,$,$);\n\ #504= IFCPERSONANDORGANIZATION(#503,#502,$);\n\ #500= IFCPOSTALADDRESS($,$,$,$,('Eksempelveien 12'),$,'Eksempelby',$,'1234','Norge');\n\ #10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,#500);", ); let r = fra_tekst(&t, "prove").unwrap(); assert_eq!( r.bygning.bygning.adresse.as_deref(), Some("Eksempelveien 12, 1234 Eksempelby") ); assert_eq!( r.bygning.bygning.energiraadgiver.as_deref(), Some("Kari Nordmann, Eksempel AS") ); } #[test] fn plassholderadresse_og_standardnavn_brukes_ikke() { let t = prove() .replace( "#10= IFCBUILDING('b',$,'Testhus',$,$,#20,$,$,.ELEMENT.,$,$,$);", "#500= IFCPOSTALADDRESS($,$,$,'Vectorworks Architect',$,$,$,$,$,$);\n\ #10= IFCBUILDING('b',$,'Default Building',$,$,#20,$,$,.ELEMENT.,$,$,#500);", ) .replace( "#3= IFCPROJECT('p',$,'Prosjekt',$,$,$,$,(#40),#2);", "#3= IFCPROJECT('p',$,'Eksempelhuset','Energianalyse',$,'Eksempelhuset','Eksisterende',(#40),#2);", ); let r = fra_tekst(&t, "prove").unwrap(); assert_eq!(r.bygning.bygning.adresse, None); assert_eq!(r.bygning.bygning.navn, "Eksempelhuset"); } #[test] fn georeferering_i_utm_gir_posisjon_for_klimavalg() { let t = prove().replace( "#4= IFCDIRECTION((0.,1.));", "#4= IFCDIRECTION((0.,1.));\n\ #600= IFCPROJECTEDCRS('EPSG:25832','ETRS89 / UTM zone 32N','ETRS89',$,$,$,$);\n\ #601= IFCMAPCONVERSION(#40,#600,613351.291,6611921.980,0.,1.,0.,$);", ); let r = fra_tekst(&t, "prove").unwrap(); let [b, l] = r .bygning .klima .posisjon .expect("posisjon fra georefereringen"); assert!( (b - 59.63).abs() < 1e-4 && (l - 11.01).abs() < 1e-4, "{b}, {l}" ); } #[test] fn vectorworks_standardplassering_brukes_ikke() { let t = prove().replace( "#4= IFCDIRECTION((0.,1.));", "#4= IFCDIRECTION((0.,1.));\n\ #600= IFCPROJECTEDCRS('EPSG:3857',$,'WGS 84',$,$,$,$);\n\ #601= IFCMAPCONVERSION(#40,#600,-8575524.35228859,4705852.25746973,0.,1.,0.,$);", ); let r = fra_tekst(&t, "prove").unwrap(); assert_eq!(r.bygning.klima.posisjon, None); assert!(r.merknader.iter().any(|m| m.contains("standardplassering"))); } #[test] fn terrenget_leses_av_de_naermeste_punktene() { // Et terreng som faller mot øst: bakken er høyere ved vestveggen enn ved østveggen. let punkter: Vec<[f64; 3]> = (0..10) .flat_map(|i| (0..10).map(move |j| [i as f64, j as f64, -1.0 - 0.2 * i as f64])) .collect(); let vest = terreng_ved(&punkter, 0.5, 5.0).unwrap(); let ost = terreng_ved(&punkter, 8.5, 5.0).unwrap(); assert!(vest > ost, "vest {vest}, øst {ost}"); assert!((vest + 1.1).abs() < 0.2, "vest {vest}"); assert!((ost + 2.7).abs() < 0.2, "øst {ost}"); assert_eq!(terreng_ved(&[], 0.0, 0.0), None); } #[test] fn grunnmur_over_terreng_av_hoydene() { // ±0 ligger 131,2 moh., terrenget 130,6 moh. og første etasje på ±0: 0,6 m over bakken. assert!((over_terreng_for_kjeller(131.2, 130.6, 0.0, 2.35) - 0.6).abs() < 1e-9); // Aldri mer enn kjellerhøyden, og aldri under null. assert_eq!(over_terreng_for_kjeller(131.2, 125.0, 0.0, 2.35), 2.35); assert_eq!(over_terreng_for_kjeller(130.0, 131.0, 0.0, 2.35), 0.0); } #[test] fn vegg_uten_geometri_utelates() { let uten = prove().replace("'Body','Tessellation'", "'Axis','Curve2D'"); let r = fra_tekst(&uten, "prove").unwrap(); assert!(r.bygning.flate.is_empty()); assert!(r .merknader .iter() .any(|m| m.contains("geometrien ikke ga noe areal"))); } }