Skip to main content

delvewright_dsl/siteplan/
mod.rs

1//! **The whole owns the space and hands out boxes** (spec-0049 §4) — pipeline
2//! stage 4, the geometric embedding of the layout graph.
3//!
4//! One campaign stage document, `site-plan.json`: the whole map's design of
5//! record. It says where the region is, which plane each place stands on, what
6//! footprint each place gets, where two places connect and through what opening,
7//! what mass the whole itself owns, and which of the brief's numbers the plan is
8//! held to.
9//!
10//! Everything here is decided **upstream of any geometry**. No block exists yet;
11//! nothing in this module reads one. What is being judged is whether the plan is
12//! a plan — whether the boxes fit in the region and not in each other, whether
13//! two places that claim to connect really touch, and whether the numbers the
14//! brief fixed still hold once the boxes are drawn.
15//!
16//! # Extent flows down, and it is unrepresentable for it to flow up
17//!
18//! The reset this stage answers was caused by parts choosing their own size and
19//! the whole becoming whatever they added up to. So [`SitePlanContent::region`]
20//! is a **required field with no derived spelling**: there is no
21//! `"region": "fit"`, no default, and no constructor anywhere that computes one
22//! from the boxes. A plan cannot state its extent as a consequence; it can only
23//! state it, and `DW0826` then refuses a box that does not fit, naming the box
24//! rather than the region. That is not a check — it is the absence of a way to
25//! write the other thing.
26//!
27//! # Seams are allocated, not discovered
28//!
29//! A seam is placed by the plan, on a face the two boxes already share, at cells
30//! the plan names. Two places therefore connect **by construction**: the
31//! two-pieces-cannot-mate failure is resolved here, where both boxes are still
32//! free to move, and never later between two finished buildings. `DW0828` and
33//! `DW0829` are what make the allocation real rather than a claim.
34//!
35//! # One authority per fact
36//!
37//! Three places where the obvious shape would have carried two:
38//!
39//! * A box's floor is its **datum** and nothing else. The spec's `min` carried a
40//!   `y` beside the declared floor, which is two numbers for one plane and no
41//!   rule about which wins; here a box is a footprint (`min`/`extent` on `x`
42//!   and `z`) standing at a [`Floor`]. §9 records the departure.
43//! * A seam's **rise is derived** from the two boxes' floors. Authoring it would
44//!   be authoring arithmetic — unlike the layout graph's `critical_path`, which
45//!   is authored precisely because it is a *choice* among many, a rise is the
46//!   consequence of where the plan already put the two places. §9 records it.
47//! * The plan's `lighting` is [`crate::AreaLighting`], the engine's
48//!   existing "which fixture, to what light level" object, not a twin of it.
49//!
50//! # No opt-out exists
51//!
52//! Not one check here has an acknowledgement, an override or an exemption
53//! field. That is deliberate and it is the cheapest possible answer to
54//! `CLAUDE.md`'s question of every escape hatch — *could the defect this hatch
55//! exists to catch supply the hatch's own proof obligation?* — because a hatch
56//! that does not exist cannot be supplied.
57//!
58//! Determinism (ADR-0006): every set and map is a `BTreeSet`/`BTreeMap` and
59//! every walk is over a slice in document order.
60
61use std::collections::{BTreeMap, BTreeSet};
62use std::num::NonZeroU32;
63
64use schemars::JsonSchema;
65use serde::{Deserialize, Serialize};
66
67use crate::AreaLighting;
68use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
69use crate::envelope::Campaign;
70use crate::ids::{DatumId, EdgeId, FactId, NodeId, ViewId, VolumeId};
71use crate::layout::{Edge, LayoutGraphContent, StationKind};
72use crate::metrics::{
73    MAX_JUMP_RISE_16, MetricKind, MetricValue, Metrics, Pitch, Reads, passable_clearance_cells,
74    passable_width_cells,
75};
76
77mod check;
78mod claim;
79mod fillcheck;
80mod ground;
81mod measure;
82mod pack;
83mod place;
84mod region;
85mod seam;
86
87pub use check::*;
88pub use claim::*;
89pub use ground::*;
90use measure::*;
91pub use pack::*;
92pub use place::*;
93use region::*;
94pub use seam::*;
95
96crate::dw_code! {
97    /// `DW0824`: the graph and the plan do not agree exactly.
98    pub const DW_PLAN_AGREEMENT: DwCode = DwCode::new("DW0824", ExitTier::Build);
99}
100
101crate::dw_code! {
102    /// `DW0826`: a box leaves the region.
103    pub const DW_BOX_LEAVES_REGION: DwCode = DwCode::new("DW0826", ExitTier::Build);
104}
105
106crate::dw_code! {
107    /// `DW0827`: two boxes overlap.
108    pub const DW_BOXES_OVERLAP: DwCode = DwCode::new("DW0827", ExitTier::Build);
109}
110
111crate::dw_code! {
112    /// `DW0828`: a seam is not on a shared face.
113    pub const DW_SEAM_NOT_SHARED: DwCode = DwCode::new("DW0828", ExitTier::Build);
114}
115
116/// **How many cells stand between two connected boxes: the wall they share.**
117///
118/// The one number every geometric check in this stage is written against, and
119/// the one an author has to know before the first box goes down. A box is the
120/// **play space** of a place — the cells a body can be in — so the shell is not
121/// inside it: it stands in this gap, and two places that connect leave exactly
122/// this much room for it. Boxes placed flush have no wall to cut a seam through
123/// and `DW0828` refuses them.
124///
125/// It is a constant rather than a literal because the authoring documents state
126/// it: [`PlanBox`]'s schema description carries this value, and
127/// `crates/dsl/tests/v14_site_plan.rs` asserts the exported description against
128/// this constant, so the rule a person reads and the rule the checks enforce
129/// cannot drift apart.
130pub const SHARED_FACE_GAP_CELLS: i64 = 1;
131
132crate::dw_code! {
133    /// `DW0829`: a seam's opening is not a standard, or does not fit.
134    pub const DW_SEAM_OPENING: DwCode = DwCode::new("DW0829", ExitTier::Build);
135}
136
137crate::dw_code! {
138    /// `DW0992`: a climb that climbs nothing (spec-0098 §2c).
139    pub const DW_CLIMB_RISES_NOTHING: DwCode = DwCode::new("DW0992", ExitTier::Build);
140}
141
142crate::dw_code! {
143    /// `DW0830`: a stair seam cannot be built at standard pitch.
144    pub const DW_STAIR_PITCH: DwCode = DwCode::new("DW0830", ExitTier::Build);
145}
146
147crate::dw_code! {
148    /// `DW0831`: a drop seam falls the wrong way, past the survivable fall, or
149    /// past the plan's declared `max_drop`.
150    pub const DW_DROP_POLICY: DwCode = DwCode::new("DW0831", ExitTier::Build);
151}
152
153crate::dw_code! {
154    /// `DW0876`: a seam does not declare a connection this engine builds
155    /// (spec-0053 §6).
156    ///
157    /// **One code, three shapes of one claim** — the claim being that this seam
158    /// states a crossing the derivation can build and the observer can measure:
159    ///
160    /// 1. it declares neither an `opening` nor a `contact`, or both;
161    /// 2. its contact's span leaves the shared face `DW0828` established;
162    /// 3. it is a contact on a `stair`, `barred` or `vision` connection.
163    ///
164    /// They are one code rather than three because the author's next action is
165    /// the same in every case — say which kind of hand-off this is and give it a
166    /// shape the engine has — and because a seam exhibiting one of them has no
167    /// crossing for any rule below to judge. A contact's width is the author's:
168    /// a front one cell wide is as legal as one fifty-five wide.
169    pub const DW_CONTACT: DwCode = DwCode::new("DW0876", ExitTier::Build);
170}
171
172crate::dw_code! {
173    /// `DW0833`: a brief identity does not hold.
174    pub const DW_IDENTITY_FALSE: DwCode = DwCode::new("DW0833", ExitTier::Build);
175}
176
177crate::dw_code! {
178    /// `DW0834`: the identity gate binds nothing. Warning — see [`identities`].
179    pub const DW_IDENTITY_EMPTY: DwCode = DwCode::new("DW0834", ExitTier::Build);
180}
181
182crate::dw_code! {
183    /// `DW0835`: a whole-owned volume enters a box.
184    pub const DW_VOLUME_IN_BOX: DwCode = DwCode::new("DW0835", ExitTier::Build);
185}
186
187crate::dw_code! {
188    /// `DW0839`: two placement authorities in one campaign — a `site-plan.json` and
189    /// a non-empty `areas[]` both present.
190    pub const DW_TWO_AUTHORITIES: DwCode = DwCode::new("DW0839", ExitTier::Build);
191}
192
193crate::dw_code! {
194    /// `DW0988`: a roof the plan has no room for (spec-0098 §7) — declared on
195    /// a sky-open box, or rising into another place.
196    pub const DW_ROOF_NO_ROOM: DwCode = DwCode::new("DW0988", ExitTier::Build);
197}
198
199crate::dw_code! {
200    /// `DW0883`: a box is not placed exactly once (spec-0059 §5). Two shapes of one
201    /// claim: a connected component of the seam graph in which no box is pinned, so
202    /// nothing places it; and a pinned box the packing also reaches, at a different
203    /// corner, so two things place it.
204    pub const DW_UNPLACED: DwCode = DwCode::new("DW0883", ExitTier::Build);
205}
206
207// ---------------------------------------------------------------------------
208// The vocabulary the derivation synthesizes (spec-0049 §5.2)
209// ---------------------------------------------------------------------------
210
211/// **The one area a site-plan campaign has.**
212///
213/// A campaign places its pieces either with `areas[]` or with a site plan, never
214/// both (`DW0839`), so a site-plan campaign has exactly one place for an NPC to
215/// stand in and one area for a quest to belong to. The name is fixed rather than
216/// authored because there is nothing to choose: the site plan is the whole map,
217/// and a second name for it would be a second way to spell one thing.
218pub const SITE_AREA: &str = "area/site";
219
220/// The anchor name the campaign's **entry** stands under.
221///
222/// A *name*, and only a name: what makes this anchor the entry is the declared
223/// entry **role** (spec-0046) the derivation gives it, which is the one thing
224/// the compiler's resolution consults. The spelling survives because a
225/// site-plan campaign's quests and NPCs may address the entry cell like any
226/// other anchor, and `spawn` is the word the rest of the vocabulary already
227/// uses; nothing resolves through it.
228pub const ENTRY_ANCHOR: &str = "spawn";
229
230/// The anchor at a place's floor centre — where quests, NPCs and waves in a
231/// site-plan campaign stand.
232///
233/// `node/near-hall` becomes `anchor/node-near-hall`, and the reshaping is not
234/// cosmetic: a campaign reaches an anchor through [`crate::ids::AnchorId`],
235/// which is `anchor/<kebab>`, so `node/<id>` — spec-0049 §5.2's spelling — is
236/// not a name any document could write. The three families (`node-`, `seam-`,
237/// `unlock-`) are disjoint by their first segment, so no two synthesized
238/// anchors can collide however the graph is named.
239#[must_use]
240pub fn node_anchor(node: &NodeId) -> String {
241    format!("anchor/node-{}", slug(node.0.as_str()))
242}
243
244/// The gate region over a `barred` seam's opening — what an `open-gate` or a
245/// `shortcut` names.
246#[must_use]
247pub fn seam_anchor(edge: &EdgeId) -> String {
248    format!("anchor/seam-{}", slug(edge.0.as_str()))
249}
250
251/// The anchor on the openable side of a one-sided `barred` seam, where a
252/// shortcut's far-side affordance stands.
253#[must_use]
254pub fn seam_unlock_anchor(edge: &EdgeId) -> String {
255    format!("anchor/unlock-{}", slug(edge.0.as_str()))
256}
257
258/// The part of an id after its kind prefix.
259fn slug(id: &str) -> &str {
260    id.split_once('/').map_or(id, |(_, rest)| rest)
261}
262
263/// **What a sealed `barred` seam stands in until content opens it.**
264///
265/// One definition, here rather than in the derivation that lays it, for the same
266/// structural reason the metrics table owns the nav model's constants: two
267/// parties need this block and they need the *same* one. The derivation writes it
268/// into the gate region and declares it on the synthesized gate anchor; every
269/// verb that needs a gate's fill block — `close-gate`, a `shortcut`'s clear, a
270/// `timed-gate`'s clock — asks [`synthesized_gate_block`] whether this campaign
271/// declares one. A copy in each place would be an agreement rather than a fact.
272pub const SEAM_BAR: &str = "minecraft:iron_bars";
273
274/// The fill block a **synthesized** gate anchor declares, or `None` when `anchor`
275/// is not one of this campaign's derived seam gates.
276///
277/// This exists because `DW0343`'s question — *can the compiler fill and clear
278/// this gate?* — used to be answered by one instrument only, the prefab registry,
279/// and a derived world has no prefab. The answer came back honest and about a
280/// smaller world than the campaign has: a `shortcut` naming the very
281/// `anchor/seam-<edge>` the derivation seals with [`SEAM_BAR`] was refused for
282/// declaring no fill block, while the block sat in the derivation's own
283/// `AnchorSpec::Gate`. Nothing was red, because the check was refusing content.
284///
285/// `None` for a campaign with no site plan, and for any anchor the derivation
286/// does not synthesize — those are the prefab registry's to answer for, and this
287/// function never overrides it.
288#[must_use]
289pub fn synthesized_gate_block(c: &Campaign, anchor: &str) -> Option<&'static str> {
290    // Asks the ONE kind authority rather than re-walking the edges, and that is
291    // the whole repair: this used to enumerate `Edge::Barred` alone, so a
292    // `close-gate`, `shortcut` or `timed-gate` naming a **gate station**
293    // (spec-0052) would have been refused by `DW0343` for declaring no fill
294    // block — a refusal whose message says "declare the gate on an anchor of a
295    // piece an area binds", which a site-plan campaign cannot do at all
296    // (`DW0839` refuses a campaign that carries both `areas[]` and a plan).
297    // A narrow binding on the general mechanism, reading as a missing feature.
298    matches!(
299        synthesized_anchor_kinds(c).get(anchor),
300        Some(StationKind::Gate)
301    )
302    .then_some(SEAM_BAR)
303}
304
305/// **Every anchor a site-plan campaign's blockout provides, and what SHAPE each
306/// one is** — the single authority behind [`synthesized_anchors`].
307///
308/// The kind travels with the name because a kind is a property of the **anchor**,
309/// not of the verb that first needed one: `synthesized_gate_block` needed to know
310/// whether a name was a gate and answered by privately re-walking the edges, and
311/// a second consumer wanting the same fact would have re-walked them again. One
312/// function answers it, and everything that needs a shape asks here.
313///
314/// The mapping, and it is total:
315///
316/// * [`ENTRY_ANCHOR`] and every reached place's `anchor/node-…` —
317///   [`StationKind::Point`], the floor centre a body stands on. Scenery
318///   (`reached: false`) has none.
319/// * every `anchor/unlock-…` — [`StationKind::Point`], where the shortcut's
320///   far-side affordance stands.
321/// * every `anchor/seam-…` — [`StationKind::Gate`], the region the derivation
322///   fills with [`SEAM_BAR`] and content opens.
323/// * every declared station — the kind its node declared (spec-0052 §3).
324///
325/// Empty for a campaign with no site plan — it has prefabs instead, and their
326/// metadata is the authority.
327#[must_use]
328pub fn synthesized_anchor_kinds(c: &Campaign) -> BTreeMap<String, StationKind> {
329    let mut out: BTreeMap<String, StationKind> = BTreeMap::new();
330    if c.site_plan.is_none() {
331        return out;
332    }
333    let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
334        return out; // `DW0824` refused the plan; there is nothing to name.
335    };
336    out.insert(ENTRY_ANCHOR.to_string(), StationKind::Point);
337    for n in &graph.nodes {
338        // Scenery (`reached: false`, spec-0098 §14) has no floor a body stands
339        // on, so it has no place anchor: a proof that floods from anchors
340        // would otherwise flood from inside a place nothing enters, and a
341        // document naming one is refused where it is written.
342        if n.reached {
343            out.insert(node_anchor(&n.id), StationKind::Point);
344        }
345        // A station whose name collides with a synthesized one is `DW0869`, and
346        // one that collides with another station is `DW0870`; both are errors,
347        // so this insert never silently reinterprets a name a campaign builds
348        // with. Inserting anyway keeps the set EXACT for the refused document
349        // too, which is what lets the kind check name the declared kind rather
350        // than a shape the author did not write.
351        for s in &n.stations {
352            out.insert(s.anchor.as_str().to_string(), s.kind);
353        }
354    }
355    for e in &graph.edges {
356        let Edge::Barred { id, opens_from, .. } = e else {
357            continue;
358        };
359        out.insert(seam_anchor(id), StationKind::Gate);
360        if !matches!(opens_from, crate::layout::OpensFrom::Either) {
361            out.insert(seam_unlock_anchor(id), StationKind::Point);
362        }
363    }
364    out
365}
366
367/// **Every anchor a site-plan campaign's blockout provides**, derived from the
368/// documents alone.
369///
370/// One authority, and that is why it lives here rather than in the derivation
371/// that places them: validation resolves a campaign's anchor references against
372/// this set, the derivation creates exactly these anchors, and a name that
373/// validated could therefore never fail to exist at build time. Two functions
374/// agreeing about the spelling is the drift this one removes.
375///
376/// Empty for a campaign with no site plan — it has prefabs instead, and their
377/// metadata is the authority.
378#[must_use]
379pub fn synthesized_anchors(c: &Campaign) -> BTreeSet<String> {
380    // The names ARE the keys of the kind table, taken rather than re-derived:
381    // two functions walking the graph and agreeing about the spelling is the
382    // exact drift this module's note exists to remove, and a station made the
383    // walk long enough that a second copy would eventually diverge.
384    synthesized_anchor_kinds(c).into_keys().collect()
385}
386
387/// **The synthesized names one PLACE owes** (spec-0050 §6) — the subset of
388/// [`synthesized_anchors`] whose bearer is this box.
389///
390/// Its own `anchor/node-…`; [`ENTRY_ANCHOR`] when it is the entry node; each
391/// `anchor/unlock-…` whose `opens_from` side it is — the side the derivation
392/// stands that affordance in; and each `barred` seam's gate region
393/// (`anchor/seam-…`) whose plane this place owns (spec-0098 §2), because the
394/// piece that owns a plane ships the gate standing in it.
395///
396/// Here rather than in `crate::detailplan` because the answer is a fact about
397/// the graph, and [`synthesized_anchors`] is the one authority for what a
398/// site-plan campaign provides — a second module deciding which names belong to
399/// which place is exactly the two-functions-agreeing-about-spelling drift that
400/// note exists to remove.
401///
402/// `crates/delvec/tests/blockout.rs`'s
403/// `the_owed_anchors_partition_the_synthesized_set` proves the two PARTITION
404/// rather than merely overlap: every synthesized name is owed by exactly one
405/// place or is a gate region no place owes. A name in neither would be one a
406/// campaign resolves and no piece is ever asked for; a name in both would be two
407/// pieces claiming one anchor.
408///
409/// Empty for a campaign with no site plan, and for a node the graph does not
410/// have.
411#[must_use]
412pub fn owed_anchors(c: &Campaign, node: &NodeId) -> BTreeSet<String> {
413    let mut out: BTreeSet<String> = BTreeSet::new();
414    if c.site_plan.is_none() {
415        return out;
416    }
417    let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
418        return out; // `DW0824` refused the plan; there is nothing to name.
419    };
420    let Some(n) = graph.nodes.iter().find(|n| &n.id == node) else {
421        return out; // `DW0842` names a row whose place the graph does not have.
422    };
423    // Scenery (`reached: false`) owes no place to stand: nothing visits it
424    // (spec-0098 §14).
425    if n.reached {
426        out.insert(node_anchor(node));
427    }
428    if &graph.entry == node {
429        out.insert(ENTRY_ANCHOR.to_string());
430    }
431    // Every station of this node (spec-0052 §6). The owed set grows **upstream**,
432    // and that one widening is what carries the whole binding chain: the
433    // `detail-plan` `anchors` map must now bind each of them, and it still
434    // refuses every key outside this set, so a binding cannot invent vocabulary
435    // and a typo cannot pass as intent.
436    for s in &n.stations {
437        out.insert(s.anchor.as_str().to_string());
438    }
439    // The gate region over a `barred` seam is owed by the place that owns the
440    // plane it stands in (spec-0098 §2): every seam lies in a plane some place
441    // owns, so its shut state is that piece's to ship.
442    let resolved = SitePlan::of(c);
443    let site = resolved.site();
444    for s in &resolved.seams {
445        if s.class != "barred" || (&s.a != node && &s.b != node) {
446            continue;
447        }
448        if site.owner(s.opening.0) == Owner::Place(node.clone()) {
449            out.insert(seam_anchor(&s.edge));
450        }
451    }
452    for e in &graph.edges {
453        let Edge::Barred { id, opens_from, .. } = e else {
454            continue;
455        };
456        let side = match opens_from {
457            crate::layout::OpensFrom::A => e.a(),
458            crate::layout::OpensFrom::B => e.b(),
459            crate::layout::OpensFrom::Either => continue,
460        };
461        if side == node {
462            out.insert(seam_unlock_anchor(id));
463        }
464    }
465    out
466}
467
468// ---------------------------------------------------------------------------
469// The document (spec-0049 §4.1)
470// ---------------------------------------------------------------------------
471
472/// The `site-plan` stage document's payload: the geometric embedding of the
473/// layout graph, and the whole map's design of record.
474#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
475#[serde(deny_unknown_fields)]
476pub struct SitePlanContent {
477    /// **The whole map's one region, in world coordinates.**
478    ///
479    /// Required, with no way to omit it and no way to derive it: the schema has
480    /// no "compute this from the boxes" spelling, so extent-flows-up is
481    /// unrepresentable rather than merely forbidden. The number comes from the
482    /// geometry brief and the identities hold the plan to it.
483    ///
484    /// The water plane is deliberately **not** site-plan surface: `horizon:
485    /// ocean` in the stage-1 world document already fixes sea level, and the
486    /// plan reads that single authority rather than restating it.
487    pub region: WorldBox,
488    /// Named ground planes the boxes stand on.
489    #[serde(default, skip_serializing_if = "Vec::is_empty")]
490    pub datums: Vec<Datum>,
491    /// **Exactly one box per graph node** (`DW0824`).
492    pub boxes: Vec<PlanBox>,
493    /// **Exactly one seam per traversal edge** (`DW0824`). A `vision` edge
494    /// carries a [`Sightline`] instead — see [`Sightline`] for why.
495    pub seams: Vec<Seam>,
496    /// The mass the WHOLE owns: the mountain a cave system is inside, the ground
497    /// under a village, the sky a silhouette needs kept empty.
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub volumes: Vec<Volume>,
500    /// The guarded comparisons binding this plan to the geometry brief's facts.
501    #[serde(default, skip_serializing_if = "Vec::is_empty")]
502    pub identities: Vec<Identity>,
503    /// One per `vision` edge (`DW0824`).
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub sightlines: Vec<Sightline>,
506    /// The named exterior vantages the walk judges the silhouette from. Optional;
507    /// a plan with zero views has that zero stated in the binding line.
508    #[serde(default, skip_serializing_if = "Vec::is_empty")]
509    pub views: Vec<View>,
510    /// One lighting setting applied to every enclosed box, so a blockout
511    /// interior is walkable at night without per-box surface.
512    ///
513    /// **The engine's existing object**, not a twin of it: [`AreaLighting`] is
514    /// already "which fixture, to what light level", and the relight pass that
515    /// consumes it is the same pass either way. A second two-field struct here
516    /// would be the private-copy defect `CLAUDE.md` names, and it would fork the
517    /// range check the moment one of them grew a third field.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub lighting: Option<AreaLighting>,
520    /// **What every cell no place claims and no volume covers becomes**
521    /// (spec-0098 §2b): `solid` rock for an enclosed site (a dungeon, a cave),
522    /// `open` ground under sky over a declared terrain for an open one (a town).
523    ///
524    /// Required, with no default: either default would be a judgement about
525    /// what kind of site this is, and that judgement is the author's. Per
526    /// region it is overridden by `volumes[]`, exactly as before.
527    pub fill: Fill,
528    /// **The deepest fall a designed drop in this plan may take**, in blocks —
529    /// the author's own policy, when they have one (`DW0831` confirms every
530    /// drop seam falls no further). Absent, no policy cap applies; the
531    /// survivable fall of an unarmoured body holds every drop either way.
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub max_drop: Option<NonZeroU32>,
534}
535
536/// What undeclared space becomes (spec-0098 §2b).
537#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
539pub enum Fill {
540    /// Every unclaimed cell holds `block` — the enclosed site, whose places are
541    /// carved out of rock.
542    Solid {
543        /// The block state the rock is.
544        block: String,
545    },
546    /// A natural ground surface under sky: at the terrain's height the
547    /// `surface` block, under it `below`, above it air.
548    Open {
549        /// The ground's shape: one flat height, or a heightmap.
550        terrain: Terrain,
551        /// The block state of the top course.
552        surface: String,
553        /// The block state under the top course.
554        below: String,
555    },
556}
557
558/// **The site's terrain: a declared heightfield** (spec-0098 §2c).
559///
560/// Every height here is the `y` of the **surface block** — the ground's top
561/// cell — so a body walks one above it.
562#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
563#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
564pub enum Terrain {
565    /// One height everywhere: the named datum is the terrain's walk plane, so
566    /// the surface block stands at the datum's `y − 1` — exactly where a box
567    /// standing on that datum has its floor course.
568    Flat {
569        /// The plane (`DW0112` if the plan declares no such datum).
570        datum: DatumId,
571    },
572    /// A greyscale image exactly the region's `x × z` pixels, in the campaign:
573    /// pixel `(px, pz)` is column `(region.min.x + px, region.min.z + pz)`,
574    /// and its surface block stands at `base_y + value × range / 255` (integer
575    /// division). The creator's own artifact — drawn, or generated by a tool
576    /// whose seed is written down.
577    Heightmap {
578        /// The image's campaign-relative path.
579        heightmap: String,
580        /// The surface `y` a black pixel stands for.
581        base_y: i64,
582        /// How many blocks a white pixel stands above `base_y`.
583        range: u32,
584    },
585}
586
587/// A box of world cells: its low corner and its extent, in blocks.
588///
589/// The cells are `min[i] ..= min[i] + extent[i] - 1` on each axis. The extent is
590/// [`NonZeroU32`] rather than a `u32` a check refuses: a zero-extent region is
591/// not a small region, it is a document that does not describe a volume, and the
592/// schema says so (`minimum: 1`) so the parse refuses it as an ordinary
593/// `DW0100`. One fewer diagnostic to write and one fewer to forget.
594#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct WorldBox {
597    /// Low corner `[x, y, z]`, in world coordinates.
598    pub min: [i64; 3],
599    /// Extent `[dx, dy, dz]`, in blocks.
600    pub extent: [NonZeroU32; 3],
601}
602
603impl WorldBox {
604    /// The high corner (inclusive).
605    #[must_use]
606    pub fn max(&self) -> [i64; 3] {
607        [
608            self.min[0] + i64::from(self.extent[0].get()) - 1,
609            self.min[1] + i64::from(self.extent[1].get()) - 1,
610            self.min[2] + i64::from(self.extent[2].get()) - 1,
611        ]
612    }
613}
614
615/// A named ground plane a box's floor sits on.
616#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
617#[serde(deny_unknown_fields)]
618pub struct Datum {
619    /// Datum id (`datum/<kebab>`), unique within the plan.
620    pub id: DatumId,
621    /// The world `y` of the walk plane.
622    pub y: i64,
623    /// What this plane is, for a reader of the plan.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub note: Option<String>,
626}
627
628/// Where a place's walk plane is.
629///
630/// Two spellings of one number, and the second is not redundant: a plane several
631/// places stand on is named once as a [`Datum`] and moved once, while a place
632/// that stands alone at its own height has no plane to name. An `identities[]`
633/// entry can only bind to a *named* one, which is the pressure toward naming.
634#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
635#[serde(rename_all = "kebab-case", deny_unknown_fields)]
636pub enum Floor {
637    /// A plane the plan names (`DW0112` if the plan declares no such datum).
638    Datum(DatumId),
639    /// A world `y` this place alone stands at.
640    Y(i64),
641}
642
643/// What closes a place overhead.
644#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
645#[serde(rename_all = "kebab-case", deny_unknown_fields)]
646pub enum Ceiling {
647    /// Cells of headroom over the walk plane. A body's feet are at the floor and
648    /// the ceiling course sits at `floor + clearance`.
649    Clearance(NonZeroU32),
650    /// A sky-open place — a courtyard, a shore, a summit, a bridge's deck:
651    /// exactly this many courses of air over the walk plane are claimed, and
652    /// **nothing above them**. An open place is precisely one that makes no
653    /// claim on the air over its headroom, so a `clearance` volume above a
654    /// courtyard is the whole reserving sky rather than two authorities over
655    /// one cell, and a place hung over the yard stands in the yard's sky.
656    Open(NonZeroU32),
657}
658
659/// What a place stands on (spec-0098 §2).
660///
661/// The third declared plane of a box's cuboid, beside [`PlanBox::floor`] (the
662/// walk plane) and [`PlanBox::ceiling`] (the headroom).
663#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
664#[serde(rename_all = "kebab-case", deny_unknown_fields)]
665pub enum Base {
666    /// The place stands on the site's ground: its claim reaches down, column
667    /// by column, to the terrain under its plot or its floor course, whichever
668    /// is lower, and the whole hands it that ground and fixes its ring.
669    #[default]
670    Ground,
671    /// The place hangs — a bridge, a gantry, a treehouse: its claim stops this
672    /// many courses under its floor course (`0` is the floor course alone). It
673    /// is handed no ground and no fixed ring, and the space under it is
674    /// whoever claims it, else the site's fill. Terrain reaching into the claim
675    /// is refused (`DW0990`).
676    Aloft(u32),
677}
678
679impl Base {
680    /// The underside courses under the floor course, on an aloft place.
681    #[must_use]
682    pub fn aloft(self) -> Option<u32> {
683        match self {
684            Base::Ground => None,
685            Base::Aloft(n) => Some(n),
686        }
687    }
688
689    /// True on the default, so a plan that does not state it does not print it.
690    #[must_use]
691    pub fn is_ground(&self) -> bool {
692        matches!(self, Base::Ground)
693    }
694}
695
696/// One place, embedded: a cuboid of the world.
697///
698/// **A box is a 3D region.** Its `min`/`extent` are the two horizontal axes;
699/// its vertical extent is three declared planes — [`PlanBox::floor`], the one
700/// authority for the walk plane; [`PlanBox::ceiling`], the headroom over it
701/// (a lid, or exactly `n` courses of sky); and [`PlanBox::base`], what it
702/// stands on (the site's ground, or `n` underside courses hung in the air).
703/// Two places conflict only where their cuboids overlap (`DW0827`); a cell no
704/// place claims is the whole's — on an `open` site, walkable ground and sky.
705///
706/// **A box is the PLAY SPACE, and connected boxes are separated by exactly one
707/// cell.** `extent` is the interior a body can stand in; the shell the blockout
708/// derivation builds is not inside it. That shell stands in the one-cell gap
709/// between two neighbours, and on the course under the floor and over the
710/// ceiling. So two places that connect are placed one cell apart on the face
711/// they share — that cell is the wall they have in common, written once — and
712/// two boxes placed flush have no wall for a seam to be cut through, which
713/// `DW0828` refuses. Worked: a box at `min: [4, 4]` with `extent: [4, 4]`
714/// occupies x 4..7, so its eastern neighbour's `min` x is 9, never 8.
715///
716/// The consequence is what makes the checks say what they look like they say:
717/// `extent` is the play space the author declared, and a plan never states a
718/// wall's thickness anywhere, because the gap is where the wall is.
719#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
720#[serde(deny_unknown_fields)]
721pub struct PlanBox {
722    /// The graph node this box embeds.
723    ///
724    /// **The ordering tooth, at the type level**: there is no way to write a box
725    /// that does not name a place, so a site plan cannot describe a space the
726    /// layout graph has not declared (spec-0049 §7.1).
727    pub node: NodeId,
728    /// **A pin**: the low corner `[x, z]` in world coordinates, when the author
729    /// chooses where this box stands. Optional (spec-0059 §2): a box with no pin
730    /// stands where the packing puts it — one cell beyond the face of the box
731    /// its first seam in document order hangs it off. At least one box of every
732    /// connected component of the seam graph is pinned, or nothing places the
733    /// component (`DW0883`); a pinned box the packing also reaches at a
734    /// different corner is refused naming both (`DW0883`). Two horizontal
735    /// numbers, never three — the vertical position is `floor`.
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub min: Option<[i64; 2]>,
738    /// Interior footprint `[dx, dz]`, in blocks — any whole number on either axis.
739    /// Two horizontal numbers, never three — the vertical size is `ceiling`.
740    ///
741    /// **This is play space, not the building.** The box covers `min` to
742    /// `min + extent - 1` inclusive, and the walls stand outside it, in the
743    /// one-cell gap that separates connected places (`DW0828`).
744    pub extent: [NonZeroU32; 2],
745    /// The walk plane.
746    pub floor: Floor,
747    /// What closes it overhead.
748    pub ceiling: Ceiling,
749    /// What it stands on: `"ground"` (the default) or `{"aloft": n}`.
750    #[serde(default, skip_serializing_if = "Base::is_ground")]
751    pub base: Base,
752    /// **The sky this place stands under from the first tick** (spec-0080
753    /// §3.2): one of `world.atmospheres[]`, painted at world setup over the
754    /// box's play space grown as far as the client's biome blend reads — the
755    /// same capability `areas[].atmosphere` is, on the
756    /// other class of place with a world box. Absent: the horizon's biome.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub atmosphere: Option<crate::ids::AtmosphereId>,
759    /// **The roof the whole reserves over this place** (spec-0098 §3): how
760    /// many courses it rises above the ceiling course and how far it overhangs
761    /// the shell on each horizontal side. Massed solid at stage 5 so a walker
762    /// sees the volume the building will take; drawn by the place's own piece
763    /// once detailed. Absent: a flat lid one course thick, which the piece owns
764    /// too. Refused on a sky-open box, and where its courses rise into another
765    /// place (`DW0988`); its eaves stop at a neighbour's wall.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub roof: Option<Roof>,
768}
769
770/// The roof a roofed place carries above its lid (spec-0098 §3).
771///
772/// Both numbers are judgements the plan states against the research record
773/// (`docs/reference/roof-and-facade-craft.md`): a 45° gable over a roof span of
774/// `W` cells rises `⌈(W − 1) / 2⌉` courses, and an eave of one cell is the
775/// idiom. Neither is inferred by the engine.
776#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
777#[serde(deny_unknown_fields)]
778pub struct Roof {
779    /// Courses the roof rises above the ceiling course. `0` is a flat roof whose
780    /// only course is the lid.
781    pub courses: u32,
782    /// Cells the roof zone overhangs the shell footprint on every horizontal
783    /// side, from the ceiling course up. `0` is a roof flush with the walls.
784    pub eaves: u32,
785}
786
787/// Which side of a box a seam sits on.
788///
789/// The engine's existing face vocabulary — the same six names a prefab's face
790/// contract writes (`compiler::faces`), so a reader who knows one knows the
791/// other and no translation table exists to disagree with itself.
792#[derive(
793    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
794)]
795#[serde(rename_all = "kebab-case")]
796pub enum Face {
797    /// `+x`.
798    East,
799    /// `-x`.
800    West,
801    /// `+y`.
802    Up,
803    /// `-y`.
804    Down,
805    /// `+z`.
806    South,
807    /// `-z`.
808    North,
809}
810
811impl Face {
812    /// The unit vector this face points along.
813    #[must_use]
814    pub fn vector(self) -> [i64; 3] {
815        match self {
816            Face::East => [1, 0, 0],
817            Face::West => [-1, 0, 0],
818            Face::Up => [0, 1, 0],
819            Face::Down => [0, -1, 0],
820            Face::South => [0, 0, 1],
821            Face::North => [0, 0, -1],
822        }
823    }
824
825    /// True for a face whose plane is horizontal (a floor or a ceiling).
826    #[must_use]
827    pub fn is_horizontal_plane(self) -> bool {
828        matches!(self, Face::Up | Face::Down)
829    }
830
831    /// The name a refusal prints.
832    #[must_use]
833    pub fn as_str(self) -> &'static str {
834        match self {
835            Face::East => "east",
836            Face::West => "west",
837            Face::Up => "up",
838            Face::Down => "down",
839            Face::South => "south",
840            Face::North => "north",
841        }
842    }
843}
844
845/// A crossing's position on one box's face, from that box's own low corner.
846///
847/// One integer on a wall face (cells along the face's horizontal axis), a pair
848/// through a floor or ceiling (cells along `x` and `z`). Untagged, so the
849/// document writes `"at": 2` or `"at": [2, 3]`; the face decides which shape is
850/// a position and the other is `DW0828`.
851#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
852#[serde(untagged)]
853pub enum Offset {
854    /// Cells along a vertical face's horizontal axis.
855    Along(i64),
856    /// Cells along `x` and `z` on a horizontal face.
857    Plane([i64; 2]),
858}
859
860/// One traversal edge, allocated: an opening on a face the two boxes share.
861///
862/// The seam carries **no rise** and **no sill**. A rise is `floor(b) − floor(a)`, which the plan
863/// has already stated by putting the two places where it put them; a second
864/// declaration of it could only ever agree or be a refusal teaching nothing the
865/// datums did not already say. `DW0830` and `DW0831` judge the derived number,
866/// and the stage-5 observer of the built bytes judges the realized one against
867/// the same derivation rather than against an author's copy of it.
868#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
869#[serde(deny_unknown_fields)]
870pub struct Seam {
871    /// The graph edge this seam allocates.
872    pub edge: EdgeId,
873    /// Which face **of the edge's `a` box** the seam sits on. The `b` box is the
874    /// neighbour across it (`DW0828`).
875    pub face: Face,
876    /// Where the crossing sits on **`a`'s** face: an offset from `a`'s own low
877    /// corner along the face, never a world coordinate (spec-0059 §2).
878    ///
879    /// * on a vertical face (`east`/`west`/`north`/`south`) — one integer, cells
880    ///   along the face's horizontal axis (`z` for east/west, `x` for
881    ///   north/south). The **sill is not written**: it is
882    ///   `max(floor(a), floor(b))`, which the plan has already stated.
883    /// * on a horizontal face (`up`/`down`) — `[dx, dz]`, cells along `x` and
884    ///   `z`.
885    ///
886    /// Omitted, the crossing is **centred** on the face: `(extent - width) div
887    /// 2`. An offset that leaves the face, or of the wrong shape for the face,
888    /// is `DW0828`.
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub at: Option<Offset>,
891    /// Where the same crossing sits on **`b`'s** face — the same offset, taken
892    /// from `b`'s own low corner. Omitted, centred on `b`'s face. The packing
893    /// places `b` so that the two agree: `corner(b) = corner(a) + at - meets`
894    /// along the face (spec-0059 §3). On a seam whose two boxes both already
895    /// stand, the two must name the same cells (`DW0828`).
896    #[serde(default, skip_serializing_if = "Option::is_none")]
897    pub meets: Option<Offset>,
898    /// **A PORTAL**: a named opening from the metrics table's standard set
899    /// (`DW0812` on a name the table does not define), or a size the seam
900    /// declares itself, `{"width": w, "height": h}` — the author's own opening,
901    /// a rope bridge's end one cell wide included. `DW0829` confirms either fits
902    /// the shared face. A body crosses at exactly the cells `at` and the opening
903    /// allocate.
904    ///
905    /// **Exactly one of this and [`Seam::contact`]** (`DW0876`).
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub opening: Option<OpeningSpec>,
908    /// **A CONTACT**: the two places simply meet along a front, rather than
909    /// through a doorway (spec-0053 §4).
910    ///
911    /// **Exactly one of this and [`Seam::opening`]** (`DW0876`).
912    ///
913    /// The width of a front where two places meet is a fact of those two boxes'
914    /// shared face — per-campaign geometry, continuous — so it is never a named
915    /// standard. A table that enumerated it would gain a new entry per campaign,
916    /// which is content reproduced in the opening set as a standard: an `opening.gate-front` of 21×4 is content wearing a standard's
917    /// clothes (spec-0053 §7).
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub contact: Option<Contact>,
920    /// Which of the edge's two boxes hosts the stair massing. Required on a
921    /// `stair` edge and refused on any other (`DW0830`, `DW0824`).
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub stair_in: Option<NodeId>,
924    /// **What the crossing is, in a few words** (spec-0098 §2c) — "a wooden
925    /// arch bridge, 3 wide", "a stone stair, down 4". A creative judgement the
926    /// plan states once and `delvec allocation` hands to BOTH places the seam
927    /// joins, so each designs its side of the interface knowing what meets it.
928    /// Never player-facing, so never translated.
929    pub form: String,
930}
931
932/// **A portal's opening**: a named standard, or a size the seam declares.
933#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
934#[serde(untagged)]
935pub enum OpeningSpec {
936    /// A named standard from the metrics table's opening set.
937    Named(String),
938    /// A size the seam declares itself.
939    Declared(DeclaredOpening),
940}
941
942/// An opening the seam sizes itself: cells on the face's own two in-plane
943/// axes — along the wall and up it on a vertical face, `x` and `z` through a
944/// floor.
945#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
946#[serde(deny_unknown_fields)]
947pub struct DeclaredOpening {
948    /// Clear width, in cells.
949    pub width: NonZeroU32,
950    /// Clear height, in cells.
951    pub height: NonZeroU32,
952}
953
954impl OpeningSpec {
955    /// The opening's size: the named standard's, through [`Metrics::resolve`]
956    /// (the one path from a name to an entry), or the declared one.
957    ///
958    /// # Errors
959    ///
960    /// [`crate::metrics::UnknownMetric`] for a name the table does not define,
961    /// which the caller turns into `DW0812`.
962    pub fn resolve(
963        &self,
964        table: &Metrics,
965        reads: &mut Reads,
966    ) -> Result<crate::metrics::Opening, crate::metrics::UnknownMetric> {
967        match self {
968            OpeningSpec::Named(name) => {
969                let entry = table.resolve(MetricKind::Opening, name)?;
970                match entry.value(reads) {
971                    MetricValue::Opening(o) => Ok(*o),
972                    _ => unreachable!("an opening entry carries an opening"),
973                }
974            }
975            OpeningSpec::Declared(o) => Ok(crate::metrics::Opening {
976                width: o.width.get(),
977                height: o.height.get(),
978            }),
979        }
980    }
981
982    /// How the opening reads in a message: the standard's name, or `WxH`.
983    #[must_use]
984    pub fn describe(&self) -> String {
985        match self {
986            OpeningSpec::Named(name) => format!("`{name}`"),
987            OpeningSpec::Declared(o) => format!("declared {}x{}", o.width, o.height),
988        }
989    }
990}
991
992/// A mass the whole itself owns.
993#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
994#[serde(deny_unknown_fields)]
995pub struct Volume {
996    /// Volume id (`volume/<kebab>`), unique within the plan.
997    pub id: VolumeId,
998    /// The cells it covers.
999    pub region: WorldBox,
1000    /// What the whole is doing with them.
1001    pub role: VolumeRole,
1002    /// What this mass is, for a reader of the plan.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub note: Option<String>,
1005    /// The block state this volume is made of. Absent, the block of its kind
1006    /// comes from the plan's `fill` (spec-0098 §2b): a `massif` is the
1007    /// `solid` fill's block, or the `open` fill's `below`; a `ground` is the
1008    /// `open` fill's `surface` over `below`, or the `solid` fill's block. A
1009    /// `clearance` is air and names no block (`DW0193` if it does).
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub block: Option<String>,
1012}
1013
1014/// What a whole-owned volume is for.
1015#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1016#[serde(rename_all = "kebab-case")]
1017pub enum VolumeRole {
1018    /// Solid mass the places are cut into — the mountain around a cave system.
1019    Massif,
1020    /// The ground the places stand on.
1021    Ground,
1022    /// Air the whole keeps empty — the sky a silhouette needs, the drop a
1023    /// vista looks over.
1024    Clearance,
1025}
1026
1027/// One guarded comparison binding the plan to a fact of the written brief.
1028#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1029#[serde(deny_unknown_fields)]
1030pub struct Identity {
1031    /// The brief fact this holds the plan to (`DW0112` if the brief has no such
1032    /// fact).
1033    pub fact: FactId,
1034    /// What is measured off the plan.
1035    pub measure: Measure,
1036    /// How the measurement must stand to the fact's value.
1037    pub cmp: Cmp,
1038}
1039
1040/// What an identity measures off the plan.
1041///
1042/// A small **fixed** vocabulary, spelled as a tagged union rather than as the
1043/// spec's `box(<node>).extent.x` string. The vocabulary is exactly the spec's
1044/// five; what changes is that a measure is parsed by serde instead of by a
1045/// grammar this module would have had to write, own and document — so an
1046/// unknown measure is an ordinary `DW0100`, a node it names is checked like
1047/// every other reference, and the growth the spec's marked judgement predicts is
1048/// a variant rather than a second escaping rule. §9 records the departure.
1049#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1050#[serde(tag = "of", rename_all = "kebab-case", deny_unknown_fields)]
1051pub enum Measure {
1052    /// The whole region's extent on one axis, in blocks.
1053    RegionExtent {
1054        /// Which axis.
1055        axis: Axis,
1056    },
1057    /// One place's footprint on one horizontal axis, in blocks.
1058    BoxExtent {
1059        /// The place.
1060        node: NodeId,
1061        /// Which horizontal axis.
1062        axis: PlanAxis,
1063    },
1064    /// One place's headroom over its walk plane, in blocks. A sky-open place
1065    /// measures the courses of air its ceiling declares.
1066    BoxHeight {
1067        /// The place.
1068        node: NodeId,
1069    },
1070    /// The horizontal distance between two places' footprint centres, in blocks.
1071    /// Euclidean on `x`/`z`; the standoff a brief states between two things.
1072    DistanceXz {
1073        /// One place.
1074        from: NodeId,
1075        /// The other.
1076        to: NodeId,
1077    },
1078    /// A named ground plane's world `y`.
1079    DatumY {
1080        /// The plane.
1081        datum: DatumId,
1082    },
1083}
1084
1085/// One of the three world axes.
1086#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1087#[serde(rename_all = "kebab-case")]
1088pub enum Axis {
1089    /// East–west.
1090    X,
1091    /// Up–down.
1092    Y,
1093    /// North–south.
1094    Z,
1095}
1096
1097/// One of the two horizontal axes.
1098///
1099/// A separate type from [`Axis`] rather than a `y` some check refuses: a box is
1100/// a footprint, so its extent has no `y` to ask about, and an unrepresentable
1101/// state needs no diagnostic to police it.
1102#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1103#[serde(rename_all = "kebab-case")]
1104pub enum PlanAxis {
1105    /// East–west.
1106    X,
1107    /// North–south.
1108    Z,
1109}
1110
1111/// How a measurement must stand to its fact's value.
1112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1113#[serde(rename_all = "kebab-case")]
1114pub enum Cmp {
1115    /// Exactly.
1116    Eq,
1117    /// Strictly under.
1118    Lt,
1119    /// At most.
1120    Le,
1121    /// Strictly over.
1122    Gt,
1123    /// At least.
1124    Ge,
1125}
1126
1127impl Cmp {
1128    fn holds(self, measured: f64, fact: f64) -> bool {
1129        match self {
1130            Cmp::Eq => (measured - fact).abs() < 1e-9,
1131            Cmp::Lt => measured < fact,
1132            Cmp::Le => measured <= fact,
1133            Cmp::Gt => measured > fact,
1134            Cmp::Ge => measured >= fact,
1135        }
1136    }
1137
1138    fn as_str(self) -> &'static str {
1139        match self {
1140            Cmp::Eq => "exactly",
1141            Cmp::Lt => "under",
1142            Cmp::Le => "at most",
1143            Cmp::Gt => "over",
1144            Cmp::Ge => "at least",
1145        }
1146    }
1147}
1148
1149/// A `vision` edge, embedded: the segment the stage-5 battery walks.
1150///
1151/// A vision edge gets a sightline rather than a seam because a vista's two ends
1152/// are routinely not adjacent — a bell tower seen from a shore shares no face
1153/// with it — so the seam construct cannot state the one thing a vision edge
1154/// asserts. A window between neighbours is simply a short sightline
1155/// (spec-0049 §4.4).
1156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1157#[serde(deny_unknown_fields)]
1158pub struct Sightline {
1159    /// The `vision` edge this embeds.
1160    pub edge: EdgeId,
1161    /// The eye end, in world coordinates — inside the edge's `a` box
1162    /// (`DW0824`).
1163    pub from: [i64; 3],
1164    /// The seen end — inside the edge's `b` box (`DW0824`).
1165    pub to: [i64; 3],
1166}
1167
1168/// A named exterior vantage the walk judges the silhouette from.
1169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1170#[serde(deny_unknown_fields)]
1171pub struct View {
1172    /// View id (`view/<kebab>`), unique within the plan.
1173    pub id: ViewId,
1174    /// Where the eye stands, in world coordinates.
1175    pub eye: [i64; 3],
1176    /// What it looks at.
1177    pub look_at: [i64; 3],
1178    /// What this view is for.
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    pub note: Option<String>,
1181}