use super::IfcModel;
use super::body_geometry::{self, Body, Placement, body_mesh, element_body};
use super::entities::IfcEntity;
use super::pbr::PbrMaterial;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GltfDocument {
pub asset: Asset,
pub scene: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenes: Vec<Scene>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<Node>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub meshes: Vec<Mesh>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub materials: Vec<Material>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub buffers: Vec<Buffer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "bufferViews")]
pub buffer_views: Vec<BufferView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accessors: Vec<Accessor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Asset {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
}
impl Default for Asset {
fn default() -> Self {
Self {
version: "2.0".into(),
generator: Some("rvt-rs VW1-04".into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scene {
pub nodes: Vec<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Node {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mesh: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matrix: Option<[f32; 16]>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extras: Option<NodeExtras>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeExtras {
#[serde(rename = "entityIndex")]
pub entity_index: usize,
#[serde(rename = "ifcType")]
pub ifc_type: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Mesh {
pub primitives: Vec<Primitive>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Primitive {
pub attributes: std::collections::BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub indices: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub material: Option<usize>,
#[serde(default)]
pub mode: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Material {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "pbrMetallicRoughness")]
pub pbr_metallic_roughness: PbrMetallicRoughness,
#[serde(default, rename = "doubleSided")]
pub double_sided: bool,
#[serde(default, rename = "alphaMode", skip_serializing_if = "Option::is_none")]
pub alpha_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PbrMetallicRoughness {
#[serde(rename = "baseColorFactor")]
pub base_color_factor: [f32; 4],
#[serde(rename = "metallicFactor")]
pub metallic_factor: f32,
#[serde(rename = "roughnessFactor")]
pub roughness_factor: f32,
}
impl Default for PbrMetallicRoughness {
fn default() -> Self {
Self {
base_color_factor: [0.75, 0.75, 0.75, 1.0],
metallic_factor: 0.0,
roughness_factor: 0.6,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Buffer {
#[serde(rename = "byteLength")]
pub byte_length: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BufferView {
pub buffer: usize,
#[serde(rename = "byteOffset", default)]
pub byte_offset: usize,
#[serde(rename = "byteLength")]
pub byte_length: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Accessor {
#[serde(rename = "bufferView")]
pub buffer_view: usize,
#[serde(rename = "byteOffset", default)]
pub byte_offset: usize,
#[serde(rename = "componentType")]
pub component_type: u32,
pub count: usize,
#[serde(rename = "type")]
pub type_: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub max: Vec<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub min: Vec<f32>,
}
fn unit_cube_vertices() -> [f32; 24 * 3] {
let h = 0.5_f32;
[
h, -h, -h, h, h, -h, h, h, h, h, -h, h, -h, -h, h, -h, h, h, -h, h, -h, -h, -h, -h, -h, h, -h, -h, h, h, h, h, h, h, h, -h, h, -h, -h, h, -h, h, -h, -h, h, -h, -h, -h, -h, -h, h, h, -h, h, h, h, h, -h, h, h, -h, -h, -h, -h, h, -h, h, h, -h, h, -h, -h,
]
}
fn unit_cube_indices() -> [u16; 36] {
[
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23, ]
}
pub const Z_UP_FEET_TO_Y_UP_METRES: [f32; 16] = [
0.3048, 0.0, 0.0, 0.0, 0.0, 0.0, -0.3048, 0.0, 0.0, 0.3048, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
pub fn build_gltf(model: &IfcModel) -> (GltfDocument, Vec<u8>) {
let mut bin = Vec::<u8>::new();
let mut doc = GltfDocument::default();
let positions = unit_cube_vertices();
let pos_bytes = bytemuck_cast_f32(&positions);
let pos_offset = 0;
let pos_len = pos_bytes.len();
bin.extend_from_slice(&pos_bytes);
pad_to_4(&mut bin);
let indices = unit_cube_indices();
let idx_bytes = bytemuck_cast_u16(&indices);
let idx_offset = bin.len();
let idx_len = idx_bytes.len();
bin.extend_from_slice(&idx_bytes);
pad_to_4(&mut bin);
doc.buffer_views.push(BufferView {
buffer: 0,
byte_offset: pos_offset,
byte_length: pos_len,
target: Some(34962), });
doc.buffer_views.push(BufferView {
buffer: 0,
byte_offset: idx_offset,
byte_length: idx_len,
target: Some(34963), });
doc.accessors.push(Accessor {
buffer_view: 0,
byte_offset: 0,
component_type: 5126, count: 24,
type_: "VEC3".into(),
min: vec![-0.5, -0.5, -0.5],
max: vec![0.5, 0.5, 0.5],
});
doc.accessors.push(Accessor {
buffer_view: 1,
byte_offset: 0,
component_type: 5123, count: 36,
type_: "SCALAR".into(),
..Accessor {
buffer_view: 0,
byte_offset: 0,
component_type: 0,
count: 0,
type_: String::new(),
max: Vec::new(),
min: Vec::new(),
}
});
for m in &model.materials {
let pbr = PbrMaterial::from_material_info(m);
let mat = Material {
name: Some(m.name.clone()),
pbr_metallic_roughness: PbrMetallicRoughness {
base_color_factor: [
pbr.base_color_rgb[0],
pbr.base_color_rgb[1],
pbr.base_color_rgb[2],
pbr.alpha,
],
metallic_factor: pbr.metallic,
roughness_factor: pbr.roughness,
},
double_sided: pbr.double_sided,
alpha_mode: if pbr.alpha < 1.0 {
Some("BLEND".into())
} else {
None
},
};
doc.materials.push(mat);
}
let mut category_materials: std::collections::BTreeMap<String, usize> = Default::default();
let mut layer_materials: std::collections::BTreeMap<(u32, u32), usize> = Default::default();
let single_material: std::collections::BTreeMap<usize, usize> = model
.material_constituent_sets
.iter()
.filter_map(|set| match set.material_indices.as_slice() {
[material]
if model
.materials
.get(*material)
.is_some_and(|info| info.color_packed.is_some()) =>
{
Some(
set.elements
.iter()
.map(move |&element| (element, *material)),
)
}
_ => None,
})
.flatten()
.chain(
model
.entities
.iter()
.enumerate()
.filter_map(|(index, entity)| match entity {
IfcEntity::BuildingElement {
material_profile_set_index: Some(set),
..
} => match model.material_profile_sets.get(*set)?.profiles.as_slice() {
[profile]
if model
.materials
.get(profile.material_index)
.is_some_and(|info| info.color_packed.is_some()) =>
{
Some((index, profile.material_index))
}
_ => None,
},
_ => None,
}),
)
.collect();
let mut scene_nodes: Vec<usize> = Vec::new();
for (entity_index, ent) in model.entities.iter().enumerate() {
let IfcEntity::BuildingElement {
ifc_type,
name,
type_guid,
material_index,
location_feet,
rotation_radians,
..
} = ent
else {
continue;
};
if ifc_type == "IFCOPENINGELEMENT" {
continue;
}
let placement = Placement::new(*location_feet, *rotation_radians);
let body = element_body(ent, &model.representation_maps);
let (c, s) = (placement.cos as f32, placement.sin as f32);
let [tx, ty, tz] = placement.origin.map(|v| v as f32);
let body_matrix = [
c,
s,
0.0,
0.0,
if s == 0.0 { 0.0 } else { -s },
c,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
tx,
ty,
tz,
1.0,
];
let layered = type_guid
.as_deref()
.and_then(|tag| tag.parse::<u32>().ok())
.and_then(|id| model.element_layers.get(&id));
if let (Some(layers), Some(Body::Extrusion(extrusion))) = (layered, body) {
let [nx, ny] = layers.exterior_normal;
let local = [
nx * placement.cos + ny * placement.sin,
-nx * placement.sin + ny * placement.cos,
];
let widths: Vec<f64> = layers.layers.iter().map(|l| l.width_feet).collect();
let meshes = if layers.stacked {
body_geometry::stacked_extrusion_meshes(extrusion, &widths)
} else {
body_geometry::layered_extrusion_meshes(extrusion, local, &widths)
};
if let Some(meshes) = meshes {
for (mesh, band) in meshes.iter().zip(&layers.layers) {
let Some((position, indices)) = push_mesh(&mut doc, &mut bin, mesh) else {
continue;
};
let material = match band.color_packed {
Some(packed) => {
let rgb = ((packed & 0xff) << 16)
| (packed & 0xff00)
| ((packed >> 16) & 0xff);
let alpha = (1.0 - band.transparency.clamp(0.0, 1.0)) as f32;
*layer_materials
.entry((rgb, alpha.to_bits()))
.or_insert_with(|| {
doc.materials.push(colour_material(
format!("Layer {rgb:06x}"),
rgb,
alpha,
));
doc.materials.len() - 1
})
}
None => *category_materials
.entry(ifc_type.clone())
.or_insert_with(|| {
doc.materials.push(category_material(ifc_type));
doc.materials.len() - 1
}),
};
let mut attributes = std::collections::BTreeMap::new();
attributes.insert("POSITION".into(), position);
let mesh_idx = doc.meshes.len();
doc.meshes.push(Mesh {
primitives: vec![Primitive {
attributes,
indices: Some(indices),
material: Some(material),
mode: 4,
}],
name: Some(name.clone()),
});
let node_idx = doc.nodes.len();
doc.nodes.push(Node {
name: Some(name.clone()),
mesh: Some(mesh_idx),
matrix: Some(body_matrix),
children: Vec::new(),
extras: Some(NodeExtras {
entity_index,
ifc_type: ifc_type.clone(),
}),
});
scene_nodes.push(node_idx);
}
continue;
}
}
let drawn: Option<((usize, usize), [f32; 16])> = match body {
Some(Body::Extrusion(ex))
if ex.profile_override.is_none()
&& [ex.width_feet, ex.depth_feet, ex.height_feet]
.iter()
.all(|v| v.is_finite() && *v > 0.0) =>
{
let (sx, sy, sz) = (
ex.width_feet as f32,
ex.depth_feet as f32,
ex.height_feet as f32,
);
Some((
(0, 1),
[
c * sx,
s * sx,
0.0,
0.0,
if s == 0.0 { 0.0 } else { -s * sy },
c * sy,
0.0,
0.0,
0.0,
0.0,
sz,
0.0,
tx,
ty,
tz + sz * 0.5,
1.0,
],
))
}
Some(body) => body_mesh(body).and_then(|mesh| {
let accessors = push_mesh(&mut doc, &mut bin, &mesh)?;
Some((accessors, body_matrix))
}),
None => None,
};
let (mesh, matrix) = match drawn {
Some(((position, indices), matrix)) => {
let material = material_index
.or_else(|| single_material.get(&entity_index).copied())
.or_else(|| {
Some(
*category_materials
.entry(ifc_type.clone())
.or_insert_with(|| {
doc.materials.push(category_material(ifc_type));
doc.materials.len() - 1
}),
)
});
let mut attributes = std::collections::BTreeMap::new();
attributes.insert("POSITION".into(), position);
let mesh_idx = doc.meshes.len();
doc.meshes.push(Mesh {
primitives: vec![Primitive {
attributes,
indices: Some(indices),
material,
mode: 4, }],
name: Some(name.clone()),
});
(Some(mesh_idx), Some(matrix))
}
None => (None, None),
};
let node_idx = doc.nodes.len();
doc.nodes.push(Node {
name: Some(name.clone()),
mesh,
matrix,
children: Vec::new(),
extras: Some(NodeExtras {
entity_index,
ifc_type: ifc_type.clone(),
}),
});
scene_nodes.push(node_idx);
}
doc.buffers.push(Buffer {
byte_length: bin.len(),
uri: None,
});
let root = doc.nodes.len();
doc.nodes.push(Node {
name: Some("model".into()),
mesh: None,
matrix: Some(Z_UP_FEET_TO_Y_UP_METRES),
children: scene_nodes,
extras: None,
});
doc.scenes.push(Scene { nodes: vec![root] });
doc.scene = Some(0);
(doc, bin)
}
pub fn category_colour(ifc_type: &str) -> (u32, f32) {
match ifc_type {
"IFCWALL" | "IFCWALLSTANDARDCASE" | "IFCCURTAINWALL" => (0xCDCED0, 1.0),
"IFCSLAB" | "IFCROOF" | "IFCCOVERING" => (0x909398, 1.0),
"IFCDOOR" => (0xF59E0B, 1.0),
"IFCWINDOW" | "IFCPLATE" => (0x8FC1FF, 0.45),
"IFCCOLUMN" => (0xDC2626, 1.0),
"IFCBEAM" | "IFCMEMBER" => (0xF87171, 1.0),
"IFCSTAIR" | "IFCSTAIRFLIGHT" | "IFCRAILING" | "IFCRAMP" | "IFCRAMPFLIGHT" => {
(0x92400E, 1.0)
}
"IFCFURNITURE" | "IFCFURNISHINGELEMENT" => (0x16A34A, 1.0),
"IFCSPACE" => (0x3D94FF, 0.12),
_ => (0xB2B4B8, 1.0),
}
}
fn category_material(ifc_type: &str) -> Material {
let (rgb, alpha) = category_colour(ifc_type);
colour_material(format!("Category {ifc_type}"), rgb, alpha)
}
fn colour_material(name: String, rgb: u32, alpha: f32) -> Material {
let linear = |shift: u32| {
let c = ((rgb >> shift) & 0xff) as f32 / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
Material {
name: Some(name),
pbr_metallic_roughness: PbrMetallicRoughness {
base_color_factor: [linear(16), linear(8), linear(0), alpha],
metallic_factor: 0.0,
roughness_factor: 0.8,
},
double_sided: alpha < 1.0,
alpha_mode: (alpha < 1.0).then(|| "BLEND".into()),
}
}
fn push_mesh(
doc: &mut GltfDocument,
bin: &mut Vec<u8>,
mesh: &body_geometry::Mesh,
) -> Option<(usize, usize)> {
if mesh.is_empty() || u32::try_from(mesh.vertices.len()).is_err() {
return None;
}
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
let positions: Vec<f32> = mesh
.vertices
.iter()
.flat_map(|v| {
let p = v.map(|c| c as f32);
for axis in 0..3 {
min[axis] = min[axis].min(p[axis]);
max[axis] = max[axis].max(p[axis]);
}
p
})
.collect();
if !positions.iter().all(|v| v.is_finite()) {
return None;
}
let indices: Vec<u8> = mesh
.triangles
.iter()
.flatten()
.flat_map(|i| i.to_le_bytes())
.collect();
let position_offset = bin.len();
bin.extend_from_slice(&bytemuck_cast_f32(&positions));
let position_view = doc.buffer_views.len();
doc.buffer_views.push(BufferView {
buffer: 0,
byte_offset: position_offset,
byte_length: bin.len() - position_offset,
target: Some(34962), });
pad_to_4(bin);
let index_offset = bin.len();
bin.extend_from_slice(&indices);
let index_view = doc.buffer_views.len();
doc.buffer_views.push(BufferView {
buffer: 0,
byte_offset: index_offset,
byte_length: indices.len(),
target: Some(34963), });
pad_to_4(bin);
let position = doc.accessors.len();
doc.accessors.push(Accessor {
buffer_view: position_view,
byte_offset: 0,
component_type: 5126, count: mesh.vertices.len(),
type_: "VEC3".into(),
min: min.to_vec(),
max: max.to_vec(),
});
let index = doc.accessors.len();
doc.accessors.push(Accessor {
buffer_view: index_view,
byte_offset: 0,
component_type: 5125, count: mesh.triangles.len() * 3,
type_: "SCALAR".into(),
min: Vec::new(),
max: Vec::new(),
});
Some((position, index))
}
pub fn write_glb(doc: &GltfDocument, bin: &[u8], out: &mut Vec<u8>) {
let json_text = serde_json::to_string(doc).expect("serialize glTF doc");
let mut json_bytes = json_text.into_bytes();
while json_bytes.len() % 4 != 0 {
json_bytes.push(b' ');
}
let mut bin_padded = bin.to_vec();
while bin_padded.len() % 4 != 0 {
bin_padded.push(0);
}
let total_len = 12 + 8 + json_bytes.len() + 8 + bin_padded.len();
out.extend_from_slice(&0x4654_6C67_u32.to_le_bytes()); out.extend_from_slice(&2_u32.to_le_bytes()); out.extend_from_slice(&(total_len as u32).to_le_bytes());
out.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); out.extend_from_slice(&json_bytes);
out.extend_from_slice(&(bin_padded.len() as u32).to_le_bytes());
out.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); out.extend_from_slice(&bin_padded);
}
pub fn model_to_glb(model: &IfcModel) -> Vec<u8> {
let (doc, bin) = build_gltf(model);
let mut out = Vec::new();
write_glb(&doc, &bin, &mut out);
out
}
fn bytemuck_cast_f32(src: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(src.len() * 4);
for &v in src {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
fn bytemuck_cast_u16(src: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(src.len() * 2);
for &v in src {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
fn pad_to_4(buf: &mut Vec<u8>) {
while buf.len() % 4 != 0 {
buf.push(0);
}
}
#[cfg(test)]
mod tests {
use super::super::Storey;
use super::super::entities::{Extrusion, IfcEntity};
use super::*;
fn mk_wall(name: &str, loc: Option<[f64; 3]>, extrusion: Option<Extrusion>) -> IfcEntity {
IfcEntity::BuildingElement {
ifc_type: "IFCWALL".into(),
name: name.into(),
type_guid: None,
predefined_type: None,
storey_index: None,
material_index: None,
property_set: None,
location_feet: loc,
rotation_radians: None,
extrusion,
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}
}
#[test]
fn every_element_node_carries_its_entity_index_and_ifc_type() {
let model = IfcModel {
entities: vec![
mk_wall("W1", Some([0.0, 0.0, 0.0]), None),
IfcEntity::Project {
name: Some("P".into()),
description: None,
long_name: None,
},
mk_wall("W2", Some([5.0, 0.0, 0.0]), None),
],
..Default::default()
};
let (doc, _) = build_gltf(&model);
let extras: Vec<(usize, &str)> = element_nodes(&doc)
.map(|n| {
let e = n.extras.as_ref().expect("node carries extras");
(e.entity_index, e.ifc_type.as_str())
})
.collect();
assert_eq!(extras, vec![(0, "IFCWALL"), (2, "IFCWALL")]);
}
#[test]
fn node_extras_serialize_as_the_camel_case_keys_the_viewer_reads() {
let model = IfcModel {
entities: vec![mk_wall("W1", None, None)],
..Default::default()
};
let (doc, _) = build_gltf(&model);
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("\"extras\""));
assert!(json.contains("\"entityIndex\""));
assert!(json.contains("\"ifcType\""));
}
#[test]
fn empty_model_produces_valid_glb_framing() {
let glb = model_to_glb(&IfcModel::default());
assert!(glb.len() >= 100);
assert_eq!(&glb[..4], b"glTF");
let ver = u32::from_le_bytes([glb[4], glb[5], glb[6], glb[7]]);
assert_eq!(ver, 2);
let total = u32::from_le_bytes([glb[8], glb[9], glb[10], glb[11]]) as usize;
assert_eq!(total, glb.len());
}
#[test]
fn glb_has_json_and_bin_chunks() {
let glb = model_to_glb(&IfcModel::default());
assert_eq!(&glb[16..20], b"JSON");
let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
let bin_type_offset = 20 + json_len + 4;
assert_eq!(&glb[bin_type_offset..bin_type_offset + 4], b"BIN\0");
}
#[test]
fn build_gltf_includes_asset_version_2() {
let (doc, _) = build_gltf(&IfcModel::default());
assert_eq!(doc.asset.version, "2.0");
assert!(doc.asset.generator.is_some());
}
#[test]
fn build_gltf_shares_mesh_accessors() {
let (doc, _) = build_gltf(&IfcModel::default());
assert_eq!(doc.buffers.len(), 1);
assert_eq!(doc.buffer_views.len(), 2);
assert_eq!(doc.accessors.len(), 2);
}
#[test]
fn each_building_element_becomes_a_node_with_mesh() {
let model = IfcModel {
entities: vec![
mk_wall(
"Wall-1",
Some([10.0, 5.0, 0.0]),
Some(Extrusion {
width_feet: 20.0,
depth_feet: 0.5,
height_feet: 10.0,
profile_override: None,
}),
),
mk_wall("Wall-2", None, None),
],
..Default::default()
};
let (doc, _) = build_gltf(&model);
assert_eq!(doc.meshes.len(), 1);
assert_eq!(element_nodes(&doc).count(), 2);
assert_eq!(doc.scenes[0].nodes.len(), 1, "the root");
assert!(doc.nodes[0].mesh.is_some());
assert!(doc.nodes[1].mesh.is_none() && doc.nodes[1].matrix.is_none());
}
fn with_rotation(mut entity: IfcEntity, rotation: f64) -> IfcEntity {
if let IfcEntity::BuildingElement {
rotation_radians, ..
} = &mut entity
{
*rotation_radians = Some(rotation);
}
entity
}
#[test]
fn a_rotated_element_is_drawn_turned() {
let wall = with_rotation(
mk_wall(
"W",
Some([3.0, 7.0, 1.0]),
Some(Extrusion::rectangle(20.0, 0.5, 10.0)),
),
std::f64::consts::FRAC_PI_2,
);
let model = IfcModel {
entities: vec![wall],
..Default::default()
};
let (doc, _) = build_gltf(&model);
let m = doc.nodes[0].matrix.unwrap();
assert!(m[0].abs() < 1e-6 && (m[1] - 20.0).abs() < 1e-5);
assert!((m[4] + 0.5).abs() < 1e-6 && m[5].abs() < 1e-6);
assert_eq!((m[12], m[13], m[14]), (3.0, 7.0, 6.0));
}
fn world_vertices(doc: &GltfDocument, bin: &[u8], node: usize) -> Vec<[f32; 3]> {
let n = &doc.nodes[node];
let mesh = &doc.meshes[n.mesh.expect("mesh")];
let accessor = &doc.accessors[mesh.primitives[0].attributes["POSITION"]];
let view = &doc.buffer_views[accessor.buffer_view];
let m = n.matrix.expect("matrix");
bin[view.byte_offset..view.byte_offset + view.byte_length]
.chunks_exact(12)
.map(|c| {
let v: Vec<f32> = c
.chunks_exact(4)
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
.collect();
[
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],
]
})
.collect()
}
#[test]
fn a_sketched_profile_is_drawn_as_its_own_mesh() {
let slab = mk_wall(
"S",
Some([10.0, 0.0, 2.0]),
Some(Extrusion::arbitrary_closed(
vec![
(0.0, 0.0),
(4.0, 0.0),
(4.0, 2.0),
(2.0, 2.0),
(2.0, 4.0),
(0.0, 4.0),
],
0.5,
)),
);
let model = IfcModel {
entities: vec![slab],
..Default::default()
};
let (doc, bin) = build_gltf(&model);
assert_eq!(doc.buffers[0].byte_length, bin.len());
let vertices = world_vertices(&doc, &bin, 0);
let xs: Vec<f32> = vertices.iter().map(|v| v[0]).collect();
let zs: Vec<f32> = vertices.iter().map(|v| v[2]).collect();
assert_eq!(xs.iter().copied().fold(f32::INFINITY, f32::min), 10.0);
assert_eq!(xs.iter().copied().fold(f32::NEG_INFINITY, f32::max), 14.0);
assert_eq!(zs.iter().copied().fold(f32::INFINITY, f32::min), 2.0);
assert_eq!(zs.iter().copied().fold(f32::NEG_INFINITY, f32::max), 2.5);
assert!(
!vertices
.iter()
.any(|v| v[0] > 12.0 + 1e-4 && v[1] > 2.0 + 1e-4)
);
let accessor = &doc.accessors[doc.meshes[0].primitives[0].attributes["POSITION"]];
assert_eq!(accessor.min, vec![0.0, 0.0, 0.0]);
assert_eq!(accessor.max, vec![4.0, 4.0, 0.5]);
}
#[test]
fn a_swept_solid_is_drawn_along_its_directrix() {
let mut beam = mk_wall("B", Some([0.0, 0.0, 0.0]), None);
if let IfcEntity::BuildingElement {
ifc_type,
solid_shape,
extrusion,
..
} = &mut beam
{
*ifc_type = "IFCBEAM".into();
*extrusion = Some(Extrusion::rectangle(1.0, 30.0, 2.0));
*solid_shape = Some(super::super::entities::SolidShape::SweptPath {
profile: super::super::entities::ProfileDef::Rectangle {
width_feet: 1.0,
depth_feet: 0.5,
},
directrix_points_feet: vec![[0.0, -10.0, 1.0], [0.0, 10.0, 1.5]],
fixed_reference: [0.0, 0.0, 1.0],
});
}
let model = IfcModel {
entities: vec![beam],
..Default::default()
};
let (doc, bin) = build_gltf(&model);
let vertices = world_vertices(&doc, &bin, 0);
let span = |axis: usize| {
let values: Vec<f32> = vertices.iter().map(|v| v[axis]).collect();
values.iter().copied().fold(f32::NEG_INFINITY, f32::max)
- values.iter().copied().fold(f32::INFINITY, f32::min)
};
assert!((span(0) - 0.5).abs() < 1e-4, "the section's width");
assert!((span(1) - 20.0).abs() < 0.05, "the directrix, not the box");
}
#[test]
fn node_transform_reflects_extrusion_dims() {
let ext = Extrusion {
width_feet: 20.0,
depth_feet: 0.5,
height_feet: 10.0,
profile_override: None,
};
let model = IfcModel {
entities: vec![mk_wall("W", Some([3.0, 7.0, 0.0]), Some(ext))],
..Default::default()
};
let (doc, _) = build_gltf(&model);
let m = doc.nodes[0].matrix.unwrap();
assert_eq!(m[0], 20.0); assert_eq!(m[5], 0.5); assert_eq!(m[10], 10.0); assert_eq!(m[12], 3.0); assert_eq!(m[13], 7.0); assert_eq!(m[14], 5.0);
}
#[test]
fn elements_without_a_material_take_their_categorys_colour() {
let mut door = mk_wall(
"D",
Some([0.0; 3]),
Some(Extrusion::rectangle(3.0, 0.2, 7.0)),
);
let mut room = mk_wall(
"R",
Some([0.0; 3]),
Some(Extrusion::rectangle(10.0, 10.0, 8.0)),
);
for (entity, kind) in [(&mut door, "IFCDOOR"), (&mut room, "IFCSPACE")] {
if let IfcEntity::BuildingElement { ifc_type, .. } = entity {
*ifc_type = kind.into();
}
}
let wall = mk_wall(
"W",
Some([0.0; 3]),
Some(Extrusion::rectangle(10.0, 0.5, 9.0)),
);
let wall2 = mk_wall(
"W2",
Some([0.0; 3]),
Some(Extrusion::rectangle(5.0, 0.5, 9.0)),
);
let model = IfcModel {
entities: vec![wall, door, room, wall2],
..Default::default()
};
let (doc, _) = build_gltf(&model);
assert_eq!(doc.materials.len(), 3);
let material_of = |mesh: usize| doc.meshes[mesh].primitives[0].material.unwrap();
assert_eq!(material_of(0), material_of(3));
let door_material = &doc.materials[material_of(1)];
assert_eq!(door_material.name.as_deref(), Some("Category IFCDOOR"));
let [r, g, b, a] = door_material.pbr_metallic_roughness.base_color_factor;
assert!(r > g && g > b && a == 1.0, "a linear amber");
let room_material = &doc.materials[material_of(2)];
assert_eq!(room_material.alpha_mode.as_deref(), Some("BLEND"));
assert!(room_material.pbr_metallic_roughness.base_color_factor[3] < 0.2);
}
#[test]
fn an_elements_own_material_wins_over_its_category() {
let mut wall = mk_wall(
"W",
Some([0.0; 3]),
Some(Extrusion::rectangle(10.0, 0.5, 9.0)),
);
if let IfcEntity::BuildingElement { material_index, .. } = &mut wall {
*material_index = Some(0);
}
let model = IfcModel {
entities: vec![wall],
materials: vec![super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: Some(0x00808080),
transparency: None,
}],
..Default::default()
};
let (doc, _) = build_gltf(&model);
assert_eq!(doc.materials.len(), 1);
assert_eq!(doc.meshes[0].primitives[0].material, Some(0));
}
#[test]
fn materials_mirror_model_materials() {
let model = IfcModel {
materials: vec![
super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: Some(0x00808080),
transparency: None,
},
super::super::MaterialInfo {
name: "Glass".into(),
color_packed: None,
transparency: Some(0.6),
},
],
..Default::default()
};
let (doc, _) = build_gltf(&model);
assert_eq!(doc.materials.len(), 2);
assert_eq!(doc.materials[0].name.as_deref(), Some("Concrete"));
assert_eq!(doc.materials[1].name.as_deref(), Some("Glass"));
assert!(doc.materials[1].double_sided); assert_eq!(doc.materials[1].alpha_mode.as_deref(), Some("BLEND"));
}
#[test]
fn json_chunk_is_valid_json() {
let model = IfcModel {
project_name: Some("Test".into()),
entities: vec![mk_wall("W1", None, None)],
..Default::default()
};
let glb = model_to_glb(&model);
let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
let json_bytes = &glb[20..20 + json_len];
let json_text = std::str::from_utf8(json_bytes)
.unwrap()
.trim_end_matches(' ');
let _: serde_json::Value =
serde_json::from_str(json_text).expect("emitted JSON must parse");
}
#[test]
fn unit_cube_has_24_vertices_and_36_indices() {
assert_eq!(unit_cube_vertices().len(), 24 * 3);
assert_eq!(unit_cube_indices().len(), 36);
}
#[test]
fn pad_to_4_rounds_up_only() {
let mut v = vec![1u8, 2, 3];
pad_to_4(&mut v);
assert_eq!(v.len(), 4);
pad_to_4(&mut v);
assert_eq!(v.len(), 4); }
#[test]
fn glb_json_and_bin_chunks_are_4_byte_aligned() {
let model = IfcModel {
entities: vec![
mk_wall("A", None, None),
mk_wall("B", None, None),
mk_wall("C", None, None),
],
..Default::default()
};
let glb = model_to_glb(&model);
let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
assert_eq!(json_len % 4, 0, "JSON chunk length not 4-aligned");
let bin_len_offset = 20 + json_len;
let bin_len = u32::from_le_bytes([
glb[bin_len_offset],
glb[bin_len_offset + 1],
glb[bin_len_offset + 2],
glb[bin_len_offset + 3],
]) as usize;
assert_eq!(bin_len % 4, 0, "BIN chunk length not 4-aligned");
}
#[test]
fn scene_zero_references_all_nodes() {
let model = IfcModel {
entities: vec![
mk_wall("a", None, None),
mk_wall("b", None, None),
mk_wall("c", None, None),
],
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
..Default::default()
};
let (doc, _) = build_gltf(&model);
assert_eq!(doc.scene, Some(0));
let root = &doc.nodes[doc.scenes[0].nodes[0]];
assert_eq!(root.children.len(), 3);
assert_eq!(root.matrix, Some(Z_UP_FEET_TO_Y_UP_METRES));
}
fn element_nodes(doc: &GltfDocument) -> impl Iterator<Item = &Node> {
doc.nodes.iter().filter(|n| n.extras.is_some())
}
#[test]
fn the_root_turns_z_up_feet_into_y_up_metres() {
let m = Z_UP_FEET_TO_Y_UP_METRES;
let apply = |p: [f32; 3]| {
[
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
]
};
assert_eq!(apply([0.0, 0.0, 10.0]), [0.0, 3.048, 0.0]);
assert_eq!(apply([0.0, 10.0, 0.0]), [0.0, 0.0, -3.048]);
assert_eq!(apply([10.0, 0.0, 0.0]), [3.048, 0.0, 0.0]);
}
}