Skip to main content

thread_manifest/
plan.rs

1//! # Plan — a level design, before it knows what it is made of
2//!
3//! The output of a layout program, and the input to the binder. The whole
4//! idea is in one sentence: **a plan never names a file.** It says *"here,
5//! facing this way, I need a column about 5.2 m tall, classical, and it must
6//! be load-bearing"* — and what satisfies that is a separate, cacheable
7//! question, answered later by [the Quarry](https://quarry.pixygon.io) or by
8//! commissioning a new model.
9//!
10//! That separation is what makes layout reproducible. The plan is computed
11//! by a verified program from a brief and a seed, so the same brief rebuilds
12//! the same place forever; the *assets* it resolves to may improve over time
13//! without the design changing at all.
14//!
15//! Like [`crate::model`], a plan is a flat list of uniform records — the only
16//! shape a non-recursive value language can express, and (again) the shape
17//! that turned out to read better anyway: a plan is a bill of requirements
18//! you can hand to someone.
19
20use serde::{Deserialize, Serialize};
21
22/// A laid-out place: its ground, what it needs, and how you get in and out.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct Plan {
25    #[serde(default = "untitled")]
26    pub name: String,
27    #[serde(default)]
28    pub description: String,
29    /// The style and material family the whole place keeps to. Commissions
30    /// inherit it; off-palette matches are allowed but never preferred.
31    #[serde(default)]
32    pub palette: Palette,
33    /// Geometry the browser draws **directly** — ground, walls, copings,
34    /// plinths. Cheap stone should not cost a model fetch: a wall segment is
35    /// a box, and asking a store for one (or commissioning it!) would be
36    /// silly. Only things worth *being* a model go in [`Plan::needs`].
37    #[serde(default, alias = "regions")]
38    pub builds: Vec<Build>,
39    /// The bill of requirements: what goes where, described by what it must
40    /// BE rather than which file it is.
41    #[serde(default)]
42    pub needs: Vec<Need>,
43    /// Doorways out of this place.
44    #[serde(default)]
45    pub veils: Vec<Veil>,
46    #[serde(default)]
47    pub spawns: Vec<PlanSpawn>,
48    #[serde(default)]
49    pub lights: Vec<PlanLight>,
50    /// Named boards — the place's own words.
51    #[serde(default)]
52    pub signs: Vec<Sign>,
53}
54
55fn untitled() -> String {
56    "place".to_string()
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
60pub struct Palette {
61    /// `classical` · `rustic` · `industrial` …
62    #[serde(default)]
63    pub style: String,
64    /// Material petnames the binder prefers for stone/wood/metal work.
65    #[serde(default)]
66    pub stone: String,
67    #[serde(default)]
68    pub wood: String,
69    #[serde(default)]
70    pub metal: String,
71    /// Sky preset for the world this plan becomes.
72    #[serde(default)]
73    pub sky: String,
74}
75
76/// One piece of built geometry — the browser draws it from a primitive.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct Build {
79    /// `disc` (a round floor) · `slab` (a rectangular floor) · `box` (walls,
80    /// copings, lintels, plinths) · `cylinder` (posts, drums).
81    #[serde(default = "slab")]
82    pub shape: String,
83    #[serde(default)]
84    pub at: [f32; 3],
85    /// Facing, degrees — a wall segment is a box turned tangent to its ring.
86    #[serde(default)]
87    pub yaw: f32,
88    /// Radius (disc / cylinder).
89    #[serde(default)]
90    pub r: f32,
91    #[serde(default)]
92    pub w: f32,
93    #[serde(default)]
94    pub d: f32,
95    #[serde(default = "tenth")]
96    pub h: f32,
97    /// Which palette material: `stone` · `wood` · `metal` · `accent`, or a
98    /// literal colour `"r g b"`.
99    #[serde(default)]
100    pub material: String,
101    /// Blocks walking. Floors are solid; copings and trim usually are not.
102    #[serde(default = "yes")]
103    pub solid: bool,
104    #[serde(default)]
105    pub name: String,
106}
107
108fn slab() -> String {
109    "slab".to_string()
110}
111fn tenth() -> f32 {
112    0.1
113}
114
115/// One thing the place needs, described so a store can answer it.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct Need {
118    /// What it is: `column` · `arch` · `stairs` · `table` · `rock` …
119    pub kind: String,
120    #[serde(default)]
121    pub at: [f32; 3],
122    /// Facing, degrees (the manifest's yaw convention).
123    #[serde(default)]
124    pub yaw: f32,
125    /// Wanted size in metres; 0 means "don't care about this axis".
126    #[serde(default)]
127    pub w: f32,
128    #[serde(default)]
129    pub h: f32,
130    #[serde(default)]
131    pub d: f32,
132    /// How far off the wanted size is still acceptable.
133    #[serde(default = "quarter")]
134    pub tol: f32,
135    /// Style hint, else the plan's palette.
136    #[serde(default)]
137    pub style: String,
138    #[serde(default)]
139    pub tags: Vec<String>,
140    /// If nothing fits, may the binder commission one? (Almost always yes;
141    /// `false` means "skip it rather than invent it".)
142    #[serde(default = "yes")]
143    pub commission: bool,
144    /// Does the place fail without it? Reported by the binder.
145    #[serde(default = "yes")]
146    pub must: bool,
147    /// Blocks walking (the manifest's `solid`).
148    #[serde(default = "yes")]
149    pub solid: bool,
150    #[serde(default)]
151    pub name: String,
152}
153
154fn quarter() -> f32 {
155    0.25
156}
157fn yes() -> bool {
158    true
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct Veil {
163    #[serde(default)]
164    pub at: [f32; 3],
165    #[serde(default)]
166    pub yaw: f32,
167    /// Locator; empty means "the brief will fill this in".
168    #[serde(default)]
169    pub to: String,
170    #[serde(default)]
171    pub label: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct PlanSpawn {
176    #[serde(default)]
177    pub at: [f32; 3],
178    #[serde(default)]
179    pub yaw: f32,
180    #[serde(default)]
181    pub name: String,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct PlanLight {
186    #[serde(default)]
187    pub at: [f32; 3],
188    #[serde(default = "one_f")]
189    pub warm: f32,
190    #[serde(default = "eight")]
191    pub range: f32,
192    #[serde(default = "one_f")]
193    pub intensity: f32,
194    /// Draw a lamp post under it (else the light just is).
195    #[serde(default = "yes")]
196    pub fixture: bool,
197}
198
199fn one_f() -> f32 {
200    1.0
201}
202fn eight() -> f32 {
203    8.0
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct Sign {
208    #[serde(default)]
209    pub at: [f32; 3],
210    #[serde(default)]
211    pub yaw: f32,
212    #[serde(default)]
213    pub text: String,
214    #[serde(default = "two")]
215    pub w: f32,
216    #[serde(default = "one_and_a_half")]
217    pub h: f32,
218}
219
220fn two() -> f32 {
221    2.0
222}
223fn one_and_a_half() -> f32 {
224    1.5
225}
226
227/// Metric discipline, named. A layout that ignores these reads wrong before
228/// anyone can say why, so they live in one place and get referenced rather
229/// than retyped.
230pub mod metric {
231    /// A door a person walks through without ducking or feeling lost.
232    pub const DOOR_W: f32 = 1.1;
233    pub const DOOR_H: f32 = 2.3;
234    /// A corridor two people pass in.
235    pub const CORRIDOR_W: f32 = 1.8;
236    /// Ceilings read generous at about 1.6 × the door.
237    pub const CEILING_MIN: f32 = DOOR_H * 1.6;
238    /// A veil is 2 × 3 m and fires within this radius — a spawn any closer
239    /// puts the visitor inside the doorway (world lint L3).
240    pub const VEIL_CLEAR: f32 = 3.0;
241    /// A plaza that feels like a room, not a field.
242    pub const PLAZA_MIN: f32 = 12.0;
243    pub const PLAZA_MAX: f32 = 20.0;
244    /// Lamps this far apart leave no dark ground at range 8–9.
245    pub const LAMP_SPACING: f32 = 7.0;
246}
247
248impl Plan {
249    /// Check the things that make a layout walkable rather than merely
250    /// present. Advisory like the world linter — findings name the fault.
251    pub fn check(&self) -> Vec<String> {
252        let mut out = Vec::new();
253        if self.spawns.is_empty() {
254            out.push("no spawn: nobody can arrive".into());
255        }
256        for s in &self.spawns {
257            for v in &self.veils {
258                let d = dist(s.at, v.at);
259                if d < metric::VEIL_CLEAR {
260                    out.push(format!(
261                        "spawn '{}' is {d:.1} m from a veil — arrivals land inside the doorway (keep {:.1} m)",
262                        s.name, metric::VEIL_CLEAR
263                    ));
264                }
265            }
266        }
267        if !self
268            .builds
269            .iter()
270            .any(|b| matches!(b.shape.as_str(), "disc" | "slab"))
271        {
272            out.push("no ground: the place has no floor".into());
273        }
274        if self.lights.is_empty() && self.palette.sky.is_empty() {
275            out.push("no lights and no sky — this place renders dark".into());
276        }
277        // Needs that would sit inside each other.
278        for (i, a) in self.needs.iter().enumerate() {
279            for b in self.needs.iter().skip(i + 1) {
280                let clearance = ((a.w.max(a.d) + b.w.max(b.d)) / 2.0) * 0.6;
281                if a.solid && b.solid && dist(a.at, b.at) < clearance {
282                    out.push(format!(
283                        "'{}' and '{}' overlap at {:?}",
284                        label(a, i),
285                        label(b, i + 1),
286                        a.at
287                    ));
288                }
289            }
290        }
291        out
292    }
293}
294
295fn label(n: &Need, i: usize) -> String {
296    if n.name.is_empty() {
297        format!("{} #{i}", n.kind)
298    } else {
299        n.name.clone()
300    }
301}
302
303fn dist(a: [f32; 3], b: [f32; 3]) -> f32 {
304    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
305    (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn a_plan_is_a_bill_of_requirements_and_checks_itself() {
314        let p: Plan = serde_json::from_str(
315            r#"{ "name": "hall",
316                 "palette": { "style": "classical", "stone": "marble", "sky": "dusk" },
317                 "builds": [ { "shape": "disc", "r": 12, "material": "stone" } ],
318                 "needs": [
319                   { "kind": "column", "at": [8,0,0], "h": 5.2, "w": 0.9, "d": 0.9 },
320                   { "kind": "column", "at": [-8,0,0], "h": 5.2, "w": 0.9, "d": 0.9 }
321                 ],
322                 "spawns": [ { "at": [0,0,10] } ],
323                 "veils": [ { "at": [0,1.4,-11], "label": "Onward" } ],
324                 "lights": [ { "at": [0,3,0] } ] }"#,
325        )
326        .unwrap();
327        assert_eq!(p.needs.len(), 2);
328        assert!(p.needs[0].commission, "commissioning is the default");
329        assert!(p.check().is_empty(), "a sound plan: {:?}", p.check());
330
331        // A veil on top of the spawn, two columns in the same spot, no floor.
332        let bad: Plan = serde_json::from_str(
333            r#"{ "needs": [ { "kind": "column", "at": [0,0,0], "w": 1, "d": 1 },
334                            { "kind": "column", "at": [0.2,0,0], "w": 1, "d": 1 } ],
335                 "spawns": [ { "at": [0,0,0] } ],
336                 "veils": [ { "at": [0,1.4,1] } ] }"#,
337        )
338        .unwrap();
339        let found = bad.check();
340        assert!(found.iter().any(|f| f.contains("doorway")), "{found:?}");
341        assert!(found.iter().any(|f| f.contains("overlap")), "{found:?}");
342        assert!(found.iter().any(|f| f.contains("floor")), "{found:?}");
343        assert!(found.iter().any(|f| f.contains("dark")), "{found:?}");
344    }
345}