Lag bygning.toml av IFC-modellen og en tilleggsfil

«enok import modell.ifc --tillegg prosjekt.toml» fletter inn det modellen ikke beskriver,
som varmeanlegg og tiltak, og rettelser der den mangler noe. Tilleggsfilen kan holde rom
utenfor oppvarmet BRA, og et navn med «*» treffer alle elementer det passer på, også i
scenarioer.

Importen er nå deterministisk: entitetene leses i fast rekkefølge, helningen arealvektes
når flater slås sammen, og skrå tak får alltid normalen oppover. Vegger får himmelretningen
i navnet, slik at tilleggsfiler kan vise til dem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-15 20:05:20 +02:00
co-authored by Claude Opus 5
parent d02a3d9666
commit 6d87ef496f
6 changed files with 348 additions and 23 deletions
+92 -3
View File
@@ -11,6 +11,7 @@
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;
@@ -29,17 +30,26 @@ 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(
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();
@@ -88,6 +98,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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);
@@ -111,6 +122,14 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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}"));
continue;
}
if ["kott", "bod", "loft", "garasje", "carport", "uoppvarmet"]
.iter()
.any(|o| lavt.contains(o))
@@ -136,6 +155,12 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
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();
@@ -398,6 +423,10 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
));
}
// 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
@@ -571,6 +600,8 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
}
};
vinduer.sort_by(|a, b| a.navn.cmp(&b.navn));
let bygning = Bygning {
bygning: BygningInfo {
navn,
@@ -668,9 +699,13 @@ fn slaa_sammen(flater: &mut Vec<Flate>) {
for f in flater.drain(..) {
match samlet.iter_mut().find(|g| nokkel(g) == nokkel(&f)) {
Some(g) => {
// Arealvektet himmelretning, slik at summen peker riktig vei.
// 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),
@@ -689,6 +724,36 @@ fn slaa_sammen(flater: &mut Vec<Flate>) {
*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 {
@@ -1150,7 +1215,10 @@ fn retning_av(d: &Del, cx: f64, cy: f64, sant_nord: f64) -> (f64, f64) {
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();
if vannrett > 0.2 {
// 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 {
@@ -1686,6 +1754,27 @@ mod tester {
);
}
#[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]
+3 -1
View File
@@ -7,4 +7,6 @@ pub mod regneark;
pub mod step;
pub mod toml_inn;
pub use toml_inn::{les_bygning, slaa_sammen_scenario};
pub use toml_inn::{
flett_tillegg, les_bygning, les_tillegg, slaa_sammen_scenario, Importvalg, Tillegg,
};
+4 -3
View File
@@ -3,7 +3,7 @@
//! Filen består av linjer på formen `#12=IFCWALL('guid',#3,'Navn',...);`. Denne modulen
//! deler filen i entiteter med type og argumenter, uten å tolke IFC-skjemaet.
use std::collections::HashMap;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub enum Arg {
@@ -58,7 +58,8 @@ impl Entitet {
}
pub struct Fil {
pub entiteter: HashMap<u64, Entitet>,
/// Sortert på entitetsnummer, slik at gjennomløp alltid kommer i samme rekkefølge.
pub entiteter: BTreeMap<u64, Entitet>,
}
impl Fil {
@@ -83,7 +84,7 @@ impl Fil {
/// Deler en STEP-fil i entiteter.
pub fn les(tekst: &str) -> Fil {
let mut entiteter = HashMap::new();
let mut entiteter = BTreeMap::new();
let mut setning = String::new();
let mut i_streng = false;
let mut i_data = false;
+123 -6
View File
@@ -1,8 +1,9 @@
//! Leser `bygning.toml` og fletter inn scenarioendringer.
//! Leser `bygning.toml` og fletter inn scenarioendringer og tilleggsfiler.
use crate::modell::{Bygning, Scenario};
use crate::Result;
use anyhow::Context;
use serde::Deserialize;
use std::path::Path;
use toml::Value;
@@ -11,19 +12,84 @@ pub fn les_bygning(sti: &Path) -> Result<Bygning> {
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))
}
/// Valg for selve importen, fra tabellen `[import]` i en tilleggsfil.
#[derive(Deserialize, Default, Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct Importvalg {
/// Rom som står uoppvarmet og holdes utenfor oppvarmet BRA. Et rom treffes når navnet
/// inneholder en av tekstene, uten hensyn til store og små bokstaver.
#[serde(default)]
pub uoppvarmede_rom: Vec<String>,
}
/// En tilleggsfil til en importert modell: det modellen ikke beskriver, som drift, varmeanlegg
/// og scenarioer, og rettelser til det den beskriver.
pub struct Tillegg {
pub endringer: Value,
pub import: Importvalg,
}
pub fn les_tillegg(sti: &Path) -> Result<Tillegg> {
let tekst = std::fs::read_to_string(sti).with_context(|| format!("leser {}", sti.display()))?;
let mut endringer: Value =
toml::from_str(&tekst).with_context(|| format!("tolker {}", sti.display()))?;
let import = match endringer.as_table_mut().and_then(|t| t.remove("import")) {
Some(v) => v
.try_into()
.with_context(|| format!("tolker [import] i {}", sti.display()))?,
None => Importvalg::default(),
};
Ok(Tillegg { endringer, import })
}
/// Fletter en tilleggsfil inn i en importert bygning, etter de samme reglene som scenarioene.
///
/// Returnerer også navnemønstrene med `*` som ikke traff noen oppføring, slik at en tilleggsfil
/// som har gått ut av takt med modellen blir synlig.
pub fn flett_tillegg(b: &Bygning, tillegg: &Value) -> Result<(Bygning, Vec<String>)> {
let mut grunn = Value::try_from(b).context("serialiserer den importerte bygningen")?;
let mut bom = Vec::new();
flett(&mut grunn, tillegg, &mut bom);
let bygning = grunn.try_into().context(
"tilleggsfilen gir en ugyldig bygningsbeskrivelse. Et navn som ikke finnes i modellen, \
legges til som ny oppføring og må da være komplett",
)?;
Ok((bygning, bom))
}
/// Lager en ny bygning der scenarioets endringer er flettet inn.
///
/// Tabeller flettes rekursivt. Tabellarrayer (`flate`, `vindu`, `oppvarming.kilde` …) matches
/// på feltet `navn` når det finnes, ellers på `type`; elementer som ikke finnes fra før legges
/// til, og `slett = true` fjerner et element. Andre verdier erstattes.
/// til, og `slett = true` fjerner et element. Et navn med `*` er et mønster som treffer alle
/// oppføringer det passer på, for eksempel `"Yttervegg*"`. Andre verdier erstattes.
pub fn slaa_sammen_scenario(b: &Bygning, s: &Scenario) -> Result<Bygning> {
let mut grunn = Value::try_from(b).context("serialiserer bygningen for scenario")?;
flett(&mut grunn, &s.endringer);
flett(&mut grunn, &s.endringer, &mut Vec::new());
grunn
.try_into()
.with_context(|| format!("scenarioet «{}» gir en ugyldig bygningsbeskrivelse", s.navn))
}
/// Om `navn` passer på mønsteret, der `*` står for vilkårlig tekst.
fn passer(moenster: &str, navn: &str) -> bool {
let deler: Vec<&str> = moenster.split('*').collect();
let (forst, sist) = (deler[0], deler[deler.len() - 1]);
if deler.len() == 1 {
return moenster == navn;
}
let Some(mut rest) = navn.strip_prefix(forst) else {
return false;
};
for del in &deler[1..deler.len() - 1] {
match rest.find(del) {
Some(i) => rest = &rest[i + del.len()..],
None => return false,
}
}
rest.ends_with(sist)
}
fn navnenokkel(v: &Value) -> Option<String> {
let t = v.as_table()?;
t.get("navn")
@@ -39,7 +105,7 @@ fn skal_slettes(v: &Value) -> bool {
.unwrap_or(false)
}
fn flett(grunn: &mut Value, over: &Value) {
fn flett(grunn: &mut Value, over: &Value, bom: &mut Vec<String>) {
match (grunn, over) {
(Value::Table(g), Value::Table(o)) => {
for (k, v) in o {
@@ -47,7 +113,7 @@ fn flett(grunn: &mut Value, over: &Value) {
continue;
}
match g.get_mut(k) {
Some(eksisterende) => flett(eksisterende, v),
Some(eksisterende) => flett(eksisterende, v, bom),
None => {
g.insert(k.clone(), v.clone());
}
@@ -57,6 +123,25 @@ fn flett(grunn: &mut Value, over: &Value) {
(Value::Array(g), Value::Array(o)) => {
for element in o {
match navnenokkel(element) {
Some(moenster) if moenster.contains('*') => {
let treffer =
|e: &Value| navnenokkel(e).is_some_and(|n| passer(&moenster, &n));
let antall = g.iter().filter(|e| treffer(e)).count();
if antall == 0 {
bom.push(moenster.clone());
} else if skal_slettes(element) {
g.retain(|e| !treffer(e));
} else {
// Mønsteret skal ikke erstatte navnene det treffer.
let mut endring = element.clone();
if let Some(t) = endring.as_table_mut() {
t.remove("navn");
}
for e in g.iter_mut().filter(|e| treffer(e)) {
flett(e, &endring, bom);
}
}
}
Some(navn) => {
let treff = g
.iter()
@@ -65,7 +150,7 @@ fn flett(grunn: &mut Value, over: &Value) {
Some(i) if skal_slettes(element) => {
g.remove(i);
}
Some(i) => flett(&mut g[i], element),
Some(i) => flett(&mut g[i], element, bom),
None if skal_slettes(element) => {}
None => g.push(element.clone()),
}
@@ -133,6 +218,38 @@ u_verdi = 0.8
assert_eq!(navn, vec!["Yttervegg", "Nytt tak"]);
}
#[test]
fn moenster_i_navn_treffer_flere_oppforinger() {
let b: Bygning = toml::from_str(GRUNN).unwrap();
let mut grunn = Value::try_from(&b).unwrap();
let mut bom = Vec::new();
let over: Value = toml::from_str(
r#"flate = [{ navn = "*", masseklasse = "lett" }, { navn = "Yttervegg*", u_verdi = 0.3 }, { navn = "Kjeller*", u_verdi = 0.2 }]"#,
)
.unwrap();
flett(&mut grunn, &over, &mut bom);
let ny: Bygning = grunn.try_into().unwrap();
assert_eq!(ny.flate.len(), 2);
assert_eq!(ny.flate[0].navn, "Yttervegg");
assert_eq!(ny.flate[0].u_verdi, Some(0.3));
assert_eq!(ny.flate[1].u_verdi, Some(0.8));
assert!(ny.flate.iter().all(|f| f.masseklasse.is_some()));
assert_eq!(bom, vec!["Kjeller*".to_string()]);
}
#[test]
fn moenster_passer() {
assert!(passer("V-2*", "V-2 #3306"));
assert!(passer("*sør", "Yttervegg, sør"));
assert!(passer(
"Kjellermur*uisolert*",
"Kjellermur 30 cm uisolert, øst"
));
assert!(!passer("V-2*", "VD-2 #11162"));
assert!(passer("Tak", "Tak"));
assert!(!passer("Tak", "Taket"));
}
#[test]
fn scenario_endrer_skalarfelt() {
let b: Bygning = toml::from_str(GRUNN).unwrap();