use ifc_model::{Edit, Entity, EntityId, Model, Transaction, Value};
use ifc_schema::{for_version, Attribute, Schema, SchemaVersion, TypeKind};
use crate::error::{ControlError, ControlResult};
#[derive(Clone, Copy)]
pub(crate) struct Release<'s> {
schema: &'s Schema,
}
impl<'s> Release<'s> {
pub(crate) const fn of_schema(schema: &'s Schema) -> Self {
Self { schema }
}
pub(crate) const fn schema(self) -> &'s Schema {
self.schema
}
fn name(self) -> String {
self.schema.name().to_owned()
}
pub(crate) fn record(
self,
entity: &'static str,
values: Vec<(&'static str, Value)>,
) -> ControlResult<Entity> {
if self.schema.entity(entity).is_none_or(|e| e.abstract_) {
return Err(ControlError::UnsupportedEntity {
schema: self.name(),
entity,
});
}
let declared = self.schema.attributes(entity);
let mut slots = vec![Value::Null; declared.len()];
for (attribute, value) in values {
let name = release_name(self.schema.version(), entity, attribute);
let Some(slot) = declared
.iter()
.position(|d| d.name.eq_ignore_ascii_case(name))
else {
if value == Value::Null {
continue;
}
return Err(ControlError::AuthoringNotInSchema {
entity,
attribute,
schema: self.name(),
});
};
if !conforms(self.schema, declared[slot], &value) {
return Err(ControlError::AuthoringValueType {
entity,
attribute,
declared: declared[slot].type_name.clone(),
schema: self.name(),
});
}
slots[slot] = value;
}
if let Some(missing) = declared
.iter()
.zip(&slots)
.find(|(d, value)| !d.optional && **value == Value::Null)
{
return Err(ControlError::AuthoringRequired {
entity,
attribute: missing.0.name.clone(),
schema: self.name(),
});
}
Ok(Entity::new(entity, slots))
}
pub(crate) fn require_owner_history(
self,
tx: &Transaction,
model: &Model,
entity: &'static str,
id: EntityId,
) -> ControlResult<()> {
let actual = projected_type(tx, model, id).ok_or(ControlError::UnknownEntity { id })?;
let accepted = self
.schema
.attributes(entity)
.iter()
.find(|d| d.name.eq_ignore_ascii_case("OwnerHistory"))
.is_some_and(|d| self.schema.accepts_type(&d.type_name, &actual));
if accepted {
Ok(())
} else {
Err(ControlError::AuthoringInvalid {
entity,
attribute: "OwnerHistory",
value: format!("{id} is {actual}, not an IfcOwnerHistory"),
})
}
}
}
pub(crate) fn bind(model: &Model) -> ControlResult<Release<'static>> {
let version = match model.header().schema.as_slice() {
[] => SchemaVersion::Ifc4,
[token] => SchemaVersion::from_header_token(token).ok_or_else(|| {
ControlError::UnsupportedSchema {
schema: token.clone(),
}
})?,
tokens => {
return Err(ControlError::MultipleSchemas {
schemas: tokens.len(),
})
}
};
let schema = for_version(version).expect("every SchemaVersion has a bundled table");
Ok(Release::of_schema(schema))
}
pub(crate) fn projected_type(tx: &Transaction, model: &Model, target: EntityId) -> Option<String> {
for edit in tx.edits().iter().rev() {
match edit {
Edit::Remove { id } if *id == target => return None,
Edit::Retype { id, type_name } if *id == target => return Some(type_name.to_string()),
Edit::Create { id, entity } if *id == target => {
return Some(entity.type_name.to_string())
}
_ => {}
}
}
model.get(target).map(|entity| entity.type_name.to_string())
}
fn release_name(
release: Option<SchemaVersion>,
entity: &str,
attribute: &'static str,
) -> &'static str {
match (release, entity, attribute) {
(Some(SchemaVersion::Ifc2x3), "IFCPERMIT", "Identification") => "PermitID",
(Some(SchemaVersion::Ifc2x3), "IFCACTIONREQUEST", "Identification") => "RequestID",
(Some(SchemaVersion::Ifc2x3), "IFCPROJECTORDER", "Identification") => "ID",
_ => attribute,
}
}
fn conforms(schema: &Schema, declared: &Attribute, value: &Value) -> bool {
match value {
Value::Null => true,
Value::List(items) => {
(declared.aggregate || is_aggregate(schema, &declared.type_name))
&& items
.iter()
.all(|item| scalar(schema, &declared.type_name, item))
}
_ => !declared.aggregate && scalar(schema, &declared.type_name, value),
}
}
fn is_aggregate(schema: &Schema, declared: &str) -> bool {
let base = schema.resolve_defined(declared).to_ascii_uppercase();
["LIST", "SET", "BAG", "ARRAY"]
.iter()
.any(|kind| base.starts_with(kind))
}
fn scalar(schema: &Schema, declared: &str, value: &Value) -> bool {
let base = schema.resolve_defined(declared).to_ascii_uppercase();
let kind = schema.type_def(declared).map(|t| &t.kind);
match value {
Value::Text(_) => base.starts_with("STRING"),
Value::Enum(token) => matches!(kind, Some(TypeKind::Enumeration(members))
if members.iter().any(|m| m.eq_ignore_ascii_case(token))),
Value::Bool(_) => base == "BOOLEAN" || base == "LOGICAL",
Value::LogicalUnknown => base == "LOGICAL",
Value::Integer(_) => base == "INTEGER" || base == "NUMBER",
Value::Real(_) => base.starts_with("REAL") || base == "NUMBER",
Value::Ref(_) => admits_entity(schema, declared, 8),
Value::Typed { type_name, .. } => {
matches!(kind, Some(TypeKind::Select(_))) && schema.accepts_type(declared, type_name)
}
_ => false,
}
}
fn admits_entity(schema: &Schema, declared: &str, depth: usize) -> bool {
if schema.entity(declared).is_some() {
return true;
}
match schema.type_def(declared).map(|t| &t.kind) {
Some(TypeKind::Select(members)) if depth > 0 => members
.iter()
.any(|member| admits_entity(schema, member, depth - 1)),
_ => false,
}
}