Files
enok/src/inndata/ifc.rs
T
blandClaude Opus 5 133152742f
Release enok / bygg (release) Successful in 1m13s
test / test (push) Successful in 1m20s
Mål grunnmuren over bakken fra overkant mur
Høyden ble målt fra gulvet i etasjen over, så dekkets egen tykkelse ble regnet som mur over
bakken. Nå måles den fra overkant av selve muren, som geometrien gir den. For Nordhusstykket
5 går høyden fra 1,11–1,50 m til 0,76–1,24 m, i snitt 0,98 m.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-09-21 15:48:52 +02:00

2199 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// Bygningsdeler som ikke er tatt med i klimaskjermen: navn, areal og U-verdi.
pub utelatt: Vec<(String, f64, Option<f64>)>,
}
/// 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<Importrapport> {
les_med(sti, &Importvalg::default())
}
pub fn les_med(sti: &Path, valg: &Importvalg) -> Result<Importrapport> {
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<Importrapport> {
fra_tekst_med(tekst, standardnavn, &Importvalg::default())
}
pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Result<Importrapport> {
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<String> = Vec::new();
let mut uoppvarmede: Vec<String> = 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<String> = 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<Del> = 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<u64>)> = 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<u64, u64> = 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<Flate> = Vec::new();
let mut flater: Vec<Flate> = Vec::new();
let mut utelatt: Vec<(String, f64, Option<f64>)> = Vec::new();
let mut uten_uverdi: Vec<String> = Vec::new();
let mut uten_geometri = 0usize;
let mut grunnmurshoyder: Vec<f64> = Vec::new();
let mut asimut_for_vegg: HashMap<u64, f64> = 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<Vindu> = 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<u64, f64> = 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::<f64>();
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<String> = 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<String> = 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<Sone> = 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<String> = 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::<f64>() / 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<Flate>) {
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<Flate> = 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<String, usize> = 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<Etasje>, HashMap<u64, usize>) {
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<u64, usize> = etasjer
.iter()
.enumerate()
.map(|(i, (r, _))| (*r, i))
.collect();
let mut for_element: HashMap<u64, usize> = 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<Masseklasse> {
let mut navn: Vec<String> = 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<String>, 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<i32>,
pub kategori: Option<String>,
pub kommune: Option<String>,
pub n50: Option<f64>,
pub ventilasjon: Option<String>,
pub gjenvinning: Option<f64>,
pub varmekilde: Option<String>,
pub oppvarmet_bra: Option<f64>,
}
/// Leser `Byggdata` fra egenskapssettene på bygningen, tomta eller prosjektet.
fn les_byggdata(fil: &Fil, egenskaper: &Egenskaper) -> (Byggdata, Vec<String>) {
let mut d = Byggdata::default();
let mut funnet = Vec::new();
let baerere: Vec<u64> = ["IFCBUILDING", "IFCSITE", "IFCPROJECT"]
.iter()
.flat_map(|t| fil.av_type(t).map(|(r, _)| r).collect::<Vec<_>>())
.collect();
let mut hent = |navn: &[&str]| -> Option<Arg> {
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<Arg>| -> Option<f64> {
let a = a?;
a.som_tall().or_else(|| {
a.som_tekst()
.and_then(|s| s.replace(',', ".").trim().parse().ok())
})
};
let tekst = |a: Option<Arg>| -> Option<String> {
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<geo::Posisjon> {
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<f64> {
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<f64> {
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::<f64>() / 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<usize>,
ytre: Option<bool>,
u_verdi: Option<f64>,
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<Kategori> {
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<VentilasjonType> {
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<VarmekildeType> {
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<String, f64>) -> 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<usize>,
/// Laveste etasje over terreng, der etasjeskilleren mot kjelleren ligger.
forste_over: Option<usize>,
/// Høyden på den etasjen, m. Geometrien veier tyngre enn etasjetilknytningen, som kan
/// være feil i eksporten.
forste_over_hoyde: Option<f64>,
}
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<String> {
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<String> {
let linjer: Vec<String> = 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::<Vec<_>>()
.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<String> {
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::<Vec<_>>()
.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<u64>, 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<u64>) -> 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<u64, HashMap<String, Arg>>;
/// 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<String, Arg>)> = 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<u64, HashMap<String, f64>> {
let mut ut: HashMap<u64, HashMap<String, f64>> = 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<u64, u64>, HashMap<u64, u64>) {
// åpning → vertselement
let mut vert_for_apning: HashMap<u64, u64> = 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<f64> {
e.get(&r)?.get(navn)?.som_tall()
}
fn u_verdi(e: &Egenskaper, r: u64) -> Option<f64> {
[
"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<bool> {
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")));
}
}