use serde::Deserialize;
use super::ir::Role;
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ManifestItem {
pub key: String,
pub glb: String,
#[allow(dead_code)]
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
pub role: Role,
pub footprint: (f32, f32),
#[serde(default)]
pub affordances: Vec<String>,
#[serde(default)]
pub surfaces: Vec<String>,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub height: f32,
#[serde(default)]
pub pivot: (f32, f32),
#[serde(default)]
pub y_offset: f32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FurnitureManifest {
pub items: Vec<ManifestItem>,
}
impl FurnitureManifest {
pub fn by_role(&self, pred: impl Fn(&Role) -> bool) -> Vec<&ManifestItem> {
self.items.iter().filter(|i| pred(&i.role)).collect()
}
}
pub const MAX_TILED_PROTOTYPES: usize = 31;
pub fn parse_manifest(text: &str) -> Result<FurnitureManifest, String> {
let manifest = ron::from_str::<FurnitureManifest>(text)
.map_err(|e| format!("manifest parse error: {e}"))?;
validate_manifest(&manifest)?;
Ok(manifest)
}
pub fn validate_manifest(manifest: &FurnitureManifest) -> Result<(), String> {
let tiled = manifest
.items
.iter()
.filter(|i| matches!(i.role, Role::Tiled))
.count();
if tiled > MAX_TILED_PROTOTYPES {
return Err(format!(
"manifest declares {tiled} `role: Tiled` items; the WFC scatter solver supports at most \
{MAX_TILED_PROTOTYPES} (its u32 prototype mask). Reduce the Tiled set or retag items."
));
}
let known = || {
super::surfaces::SURFACE_CLASSES
.iter()
.map(|(t, _)| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(", ")
};
let mut provided = 0u32;
for item in &manifest.items {
for token in &item.surfaces {
let bits = super::surfaces::surface_bits(token);
if bits == 0 {
return Err(format!(
"item `{}` offers unknown surface class `{token}` in `surfaces` — known classes: \
{} (surfaces::SURFACE_CLASSES). An unknown class provides nothing, so every prop \
targeting it would be silently dropped; add the class there or fix the token.",
item.key,
known()
));
}
provided |= bits;
}
}
for item in &manifest.items {
if let Role::Scatter { surface } = &item.role {
let need = super::surfaces::surface_bits(surface);
if need == 0 {
return Err(format!(
"scatter prop `{}` targets unknown surface class `{surface}` — known classes: {} \
(surfaces::SURFACE_CLASSES). It could never be placed.",
item.key,
known()
));
}
if provided & need == 0 {
return Err(format!(
"scatter prop `{}` targets surface class `{surface}`, but no item in this manifest \
offers it (no `surfaces` list contains it) — the prop could never be placed. Give \
some support piece `surfaces: [\"{surface}\"]` or drop the prop.",
item.key
));
}
}
}
Ok(())
}
pub fn load_manifest(path: &str) -> Result<FurnitureManifest, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
parse_manifest(&text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::placement::ir::{Host, Role};
#[test]
fn parses_roles_and_affordances() {
let text = r#"(
items: [
( key: "ceiling_light", glb: "x/Ceiling Light.glb", category: "light",
tags: ["ceiling"], role: Anchor(host: Ceiling), footprint: (0.6, 0.6),
affordances: ["emit"] ),
( key: "sofa", glb: "x/Sofa A.glb", category: "seating",
role: Freestanding, footprint: (1.9, 0.9), affordances: ["sit"] ),
],
)"#;
let m = parse_manifest(text).expect("valid manifest");
assert_eq!(m.items.len(), 2);
assert!(matches!(
m.items[0].role,
Role::Anchor {
host: Host::Ceiling
}
));
assert!(matches!(m.items[1].role, Role::Freestanding));
assert_eq!(m.by_role(|r| matches!(r, Role::Freestanding)).len(), 1);
assert_eq!(m.items[1].affordances, vec!["sit".to_string()]);
}
#[test]
fn rejects_silently_droppable_scatter_authoring() {
let ok = r#"(
items: [
( key: "desk", glb: "x/Desk.glb", category: "table", role: Freestanding,
footprint: (1.9, 0.9), surfaces: ["support", "worktop"] ),
( key: "lamp", glb: "x/Lamp.glb", category: "light",
role: Scatter(surface: "worktop"), footprint: (0.3, 0.3) ),
],
)"#;
parse_manifest(ok).expect("a provided scatter class is valid");
let unprovided = r#"(
items: [
( key: "bed", glb: "x/Bed.glb", category: "bed", role: Freestanding,
footprint: (2.0, 1.6), affordances: ["sleep"] ),
( key: "lamp", glb: "x/Lamp.glb", category: "light",
role: Scatter(surface: "worktop"), footprint: (0.3, 0.3) ),
],
)"#;
let err =
parse_manifest(unprovided).expect_err("a provider-less scatter class must be rejected");
assert!(
err.contains("lamp") && err.contains("worktop"),
"error names prop + class: {err}"
);
let bad_provider = r#"(
items: [
( key: "desk", glb: "x/Desk.glb", category: "table", role: Freestanding,
footprint: (1.9, 0.9), surfaces: ["workop"] ),
],
)"#;
let err =
parse_manifest(bad_provider).expect_err("an unknown surfaces token must be rejected");
assert!(
err.contains("desk") && err.contains("workop"),
"error names item + token: {err}"
);
let bad_target = r#"(
items: [
( key: "tv", glb: "x/TV.glb", category: "appliance",
role: Scatter(surface: "media"), footprint: (0.9, 0.3) ),
],
)"#;
let err =
parse_manifest(bad_target).expect_err("an unknown scatter class must be rejected");
assert!(
err.contains("tv") && err.contains("media"),
"error names prop + token: {err}"
);
}
#[test]
fn rejects_too_many_tiled() {
let mut body = String::new();
for i in 0..=MAX_TILED_PROTOTYPES {
body.push_str(&format!(
"( key: \"t{i}\", glb: \"x/t{i}.glb\", category: \"decor\", role: Tiled, footprint: (0.5, 0.5) ),\n"
));
}
let text = format!("( items: [ {body} ] )");
let err =
parse_manifest(&text).expect_err("more than the cap of Tiled items must be rejected");
assert!(
err.contains("Tiled"),
"error should name the Tiled cap: {err}"
);
}
}