use crate::descriptor::{Align, Descriptor, Extent, Mount, Offers, Placement};
use crate::placement::ir::{Host, Role};
use crate::placement::manifest::ManifestItem;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Policy {
pub wall_mount_height: f32,
}
const CONSTRAINT_AFFORDANCES: &[&str] = &["sit", "back_to_wall"];
const UNREAD_AFFORDANCES: &[&str] = &["sleep", "store", "decor", "hygiene", "support"];
fn is_effect(token: &str) -> bool {
!CONSTRAINT_AFFORDANCES.contains(&token) && !UNREAD_AFFORDANCES.contains(&token)
}
pub fn descriptor_from_manifest(item: &ManifestItem, policy: Policy) -> Result<Descriptor, String> {
let mount = match &item.role {
Role::Freestanding => Some(Mount::OnFloor),
Role::Tiled => Some(Mount::Tiled),
Role::Scatter { surface } => Some(Mount::OnSurface {
class: surface.clone(),
}),
Role::Anchor { host: Host::Wall } => Some(Mount::OnWall {
height: policy.wall_mount_height,
}),
Role::Anchor {
host: Host::Ceiling,
} => Some(Mount::OnCeiling),
Role::Anchor {
host: Host::Opening,
} => Some(Mount::InOpening { clear: None }),
Role::Anchor { host } => {
return Err(format!(
"`{}`: anchor host {host:?} has zero references anywhere in the tree — no shipped \
row, no spawn branch. If one is being added, decide what it means and give it a \
mount here; do not let the converter invent one.",
item.key
))
}
Role::Custom(token) => {
return Err(format!(
"`{}`: `Role::Custom({token:?})` has never been constructed in this codebase, so \
there is no behaviour to preserve. Add a real mount variant instead of reviving the \
escape hatch.",
item.key
))
}
};
Ok(Descriptor {
subgrid: Default::default(),
labels: None,
id: item.key.clone(),
mesh: Some(item.glb.clone()),
align: Align {
pivot: Some(item.pivot),
y_offset: Some(item.y_offset),
..Align::default()
},
extent: Extent {
footprint: Some(item.footprint),
height: Some(item.height),
},
mount,
clearance: Vec::new(),
offers: Offers {
surfaces: item.surfaces.clone(),
faces: Vec::new(),
sockets: Vec::new(),
},
placement: Placement {
rooms: item.tags.clone(),
group: item.group.clone(),
},
kind: if item.category.is_empty() {
Vec::new()
} else {
vec![item.category.clone()]
},
effects: item
.affordances
.iter()
.filter(|a| is_effect(a))
.cloned()
.collect(),
look: Vec::new(),
note: None,
})
}
pub fn manifest_from_descriptor(d: &Descriptor) -> Result<ManifestItem, String> {
let role = match &d.mount {
Some(Mount::OnFloor) => Role::Freestanding,
Some(Mount::Tiled) => Role::Tiled,
Some(Mount::OnSurface { class } | Mount::OnSurfaceOrFloor { class }) => Role::Scatter {
surface: class.clone(),
},
Some(Mount::OnWall { .. }) => Role::Anchor { host: Host::Wall },
Some(Mount::OnCeiling) => Role::Anchor {
host: Host::Ceiling,
},
Some(Mount::InOpening { .. }) => Role::Anchor {
host: Host::Opening,
},
Some(other) => {
return Err(format!(
"`{}`: mount {other:?} has no manifest equivalent — it is one of the layering \
relations the old schema could not express, which is why the new one exists.",
d.id
))
}
None => {
return Err(format!(
"`{}`: no mount. A manifest row must say how it is placed.",
d.id
))
}
};
Ok(ManifestItem {
key: d.id.clone(),
glb: d
.mesh
.clone()
.ok_or_else(|| format!("`{}`: no mesh", d.id))?,
category: d.kind.first().cloned().unwrap_or_default(),
tags: d.placement.rooms.clone(),
role,
footprint: d
.extent
.footprint
.ok_or_else(|| format!("`{}`: no footprint", d.id))?,
affordances: d.effects.clone(),
surfaces: d.offers.surfaces.clone(),
group: d.placement.group.clone(),
height: d.extent.height.unwrap_or_default(),
pivot: d.align.pivot.unwrap_or((0.0, 0.0)),
y_offset: d.align.y_offset.unwrap_or(0.0),
})
}
pub fn without_dropped_affordances(item: &ManifestItem) -> ManifestItem {
let mut out = item.clone();
out.affordances.retain(|a| is_effect(a));
out
}
#[cfg(test)]
mod tests {
use super::*;
const POLICY: Policy = Policy {
wall_mount_height: 1.8,
};
fn desk() -> ManifestItem {
ManifestItem {
key: "kenney/desk".into(),
glb: "kenney/desk.glb".into(),
category: "table".into(),
tags: vec!["office".into(), "living".into()],
role: Role::Freestanding,
footprint: (1.2, 0.6),
affordances: vec!["store".into(), "back_to_wall".into()],
surfaces: vec!["support".into(), "worktop".into()],
group: Some("office".into()),
height: 0.75,
pivot: (0.0, -0.14),
y_offset: 0.0,
}
}
#[test]
fn a_freestanding_item_round_trips() {
let item = desk();
let d = descriptor_from_manifest(&item, POLICY).unwrap_or_else(|e| panic!("{e}"));
let back = manifest_from_descriptor(&d).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(back, without_dropped_affordances(&item));
}
#[test]
fn scatter_becomes_a_surface_mount() {
let mut mug = desk();
mug.key = "kenney/mug".into();
mug.role = Role::Scatter {
surface: "worktop".into(),
};
let d = descriptor_from_manifest(&mug, POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
d.mount,
Some(Mount::OnSurface {
class: "worktop".into()
})
);
assert_eq!(
manifest_from_descriptor(&d).unwrap_or_else(|e| panic!("{e}")).role,
mug.role
);
}
#[test]
fn a_wall_anchor_takes_its_height_from_project_policy() {
let mut sconce = desk();
sconce.role = Role::Anchor { host: Host::Wall };
let d = descriptor_from_manifest(&sconce, POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.mount, Some(Mount::OnWall { height: 1.8 }));
let taller = descriptor_from_manifest(&sconce, Policy { wall_mount_height: 2.2 })
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(taller.mount, Some(Mount::OnWall { height: 2.2 }));
}
#[test]
fn only_affordances_something_reads_become_effects() {
let mut lamp = desk();
lamp.affordances = vec![
"emit".into(),
"sit".into(),
"store".into(),
"decor".into(),
"screen".into(),
];
let d = descriptor_from_manifest(&lamp, POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.effects, vec!["emit".to_string(), "screen".to_string()]);
}
#[test]
fn room_tags_and_grouping_survive_on_the_placement_block() {
let d = descriptor_from_manifest(&desk(), POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.placement.rooms, vec!["office".to_string(), "living".to_string()]);
assert_eq!(d.placement.group.as_deref(), Some("office"));
assert!(!d.kind.contains(&"office".to_string()));
assert!(!d.effects.contains(&"office".to_string()));
}
#[test]
fn category_becomes_the_kind_axis() {
let d = descriptor_from_manifest(&desk(), POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.kind, vec!["table".to_string()]);
}
#[test]
fn a_custom_role_is_refused_with_its_reason() {
let mut odd = desk();
odd.role = Role::Custom("hovering".into());
let err = descriptor_from_manifest(&odd, POLICY).err().unwrap_or_default();
assert!(err.contains("never been constructed"), "{err}");
assert!(err.contains("kenney/desk"), "must name the item: {err}");
}
#[test]
fn an_anchor_host_with_no_shipped_row_is_refused_rather_than_guessed() {
let mut odd = desk();
odd.role = Role::Anchor { host: Host::Floor };
let err = descriptor_from_manifest(&odd, POLICY).err().unwrap_or_default();
assert!(err.contains("zero references"), "{err}");
assert!(err.contains("kenney/desk"), "must name the item: {err}");
}
#[test]
fn an_opening_anchor_migrates_even_though_nothing_spawns_it() {
let mut door = desk();
door.key = "kenney/door".into();
door.role = Role::Anchor {
host: Host::Opening,
};
let d = descriptor_from_manifest(&door, POLICY).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.mount, Some(Mount::InOpening { clear: None }));
assert_eq!(
manifest_from_descriptor(&d).unwrap_or_else(|e| panic!("{e}")).role,
door.role
);
}
}