use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Plan {
#[serde(default = "untitled")]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub palette: Palette,
#[serde(default, alias = "regions")]
pub builds: Vec<Build>,
#[serde(default)]
pub needs: Vec<Need>,
#[serde(default)]
pub veils: Vec<Veil>,
#[serde(default)]
pub spawns: Vec<PlanSpawn>,
#[serde(default)]
pub lights: Vec<PlanLight>,
#[serde(default)]
pub signs: Vec<Sign>,
}
fn untitled() -> String {
"place".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Palette {
#[serde(default)]
pub style: String,
#[serde(default)]
pub stone: String,
#[serde(default)]
pub wood: String,
#[serde(default)]
pub metal: String,
#[serde(default)]
pub sky: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Build {
#[serde(default = "slab")]
pub shape: String,
#[serde(default)]
pub at: [f32; 3],
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub r: f32,
#[serde(default)]
pub w: f32,
#[serde(default)]
pub d: f32,
#[serde(default = "tenth")]
pub h: f32,
#[serde(default)]
pub material: String,
#[serde(default = "yes")]
pub solid: bool,
#[serde(default)]
pub name: String,
}
fn slab() -> String {
"slab".to_string()
}
fn tenth() -> f32 {
0.1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Need {
pub kind: String,
#[serde(default)]
pub at: [f32; 3],
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub w: f32,
#[serde(default)]
pub h: f32,
#[serde(default)]
pub d: f32,
#[serde(default = "quarter")]
pub tol: f32,
#[serde(default)]
pub style: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "yes")]
pub commission: bool,
#[serde(default = "yes")]
pub must: bool,
#[serde(default = "yes")]
pub solid: bool,
#[serde(default)]
pub name: String,
}
fn quarter() -> f32 {
0.25
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Veil {
#[serde(default)]
pub at: [f32; 3],
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub to: String,
#[serde(default)]
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanSpawn {
#[serde(default)]
pub at: [f32; 3],
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanLight {
#[serde(default)]
pub at: [f32; 3],
#[serde(default = "one_f")]
pub warm: f32,
#[serde(default = "eight")]
pub range: f32,
#[serde(default = "one_f")]
pub intensity: f32,
#[serde(default = "yes")]
pub fixture: bool,
}
fn one_f() -> f32 {
1.0
}
fn eight() -> f32 {
8.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sign {
#[serde(default)]
pub at: [f32; 3],
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub text: String,
#[serde(default = "two")]
pub w: f32,
#[serde(default = "one_and_a_half")]
pub h: f32,
}
fn two() -> f32 {
2.0
}
fn one_and_a_half() -> f32 {
1.5
}
pub mod metric {
pub const DOOR_W: f32 = 1.1;
pub const DOOR_H: f32 = 2.3;
pub const CORRIDOR_W: f32 = 1.8;
pub const CEILING_MIN: f32 = DOOR_H * 1.6;
pub const VEIL_CLEAR: f32 = 3.0;
pub const PLAZA_MIN: f32 = 12.0;
pub const PLAZA_MAX: f32 = 20.0;
pub const LAMP_SPACING: f32 = 7.0;
}
impl Plan {
pub fn check(&self) -> Vec<String> {
let mut out = Vec::new();
if self.spawns.is_empty() {
out.push("no spawn: nobody can arrive".into());
}
for s in &self.spawns {
for v in &self.veils {
let d = dist(s.at, v.at);
if d < metric::VEIL_CLEAR {
out.push(format!(
"spawn '{}' is {d:.1} m from a veil — arrivals land inside the doorway (keep {:.1} m)",
s.name, metric::VEIL_CLEAR
));
}
}
}
if !self
.builds
.iter()
.any(|b| matches!(b.shape.as_str(), "disc" | "slab"))
{
out.push("no ground: the place has no floor".into());
}
if self.lights.is_empty() && self.palette.sky.is_empty() {
out.push("no lights and no sky — this place renders dark".into());
}
for (i, a) in self.needs.iter().enumerate() {
for b in self.needs.iter().skip(i + 1) {
let clearance = ((a.w.max(a.d) + b.w.max(b.d)) / 2.0) * 0.6;
if a.solid && b.solid && dist(a.at, b.at) < clearance {
out.push(format!(
"'{}' and '{}' overlap at {:?}",
label(a, i),
label(b, i + 1),
a.at
));
}
}
}
out
}
}
fn label(n: &Need, i: usize) -> String {
if n.name.is_empty() {
format!("{} #{i}", n.kind)
} else {
n.name.clone()
}
}
fn dist(a: [f32; 3], b: [f32; 3]) -> f32 {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plan_is_a_bill_of_requirements_and_checks_itself() {
let p: Plan = serde_json::from_str(
r#"{ "name": "hall",
"palette": { "style": "classical", "stone": "marble", "sky": "dusk" },
"builds": [ { "shape": "disc", "r": 12, "material": "stone" } ],
"needs": [
{ "kind": "column", "at": [8,0,0], "h": 5.2, "w": 0.9, "d": 0.9 },
{ "kind": "column", "at": [-8,0,0], "h": 5.2, "w": 0.9, "d": 0.9 }
],
"spawns": [ { "at": [0,0,10] } ],
"veils": [ { "at": [0,1.4,-11], "label": "Onward" } ],
"lights": [ { "at": [0,3,0] } ] }"#,
)
.unwrap();
assert_eq!(p.needs.len(), 2);
assert!(p.needs[0].commission, "commissioning is the default");
assert!(p.check().is_empty(), "a sound plan: {:?}", p.check());
let bad: Plan = serde_json::from_str(
r#"{ "needs": [ { "kind": "column", "at": [0,0,0], "w": 1, "d": 1 },
{ "kind": "column", "at": [0.2,0,0], "w": 1, "d": 1 } ],
"spawns": [ { "at": [0,0,0] } ],
"veils": [ { "at": [0,1.4,1] } ] }"#,
)
.unwrap();
let found = bad.check();
assert!(found.iter().any(|f| f.contains("doorway")), "{found:?}");
assert!(found.iter().any(|f| f.contains("overlap")), "{found:?}");
assert!(found.iter().any(|f| f.contains("floor")), "{found:?}");
assert!(found.iter().any(|f| f.contains("dark")), "{found:?}");
}
}