delvewright_dsl/siteplan.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::stages::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::diagnostic::{Diagnostic, DwCode, ExitTier};
68use crate::envelope::Campaign;
69use crate::ids::{DatumId, EdgeId, FactId, NodeId, ViewId, VolumeId};
70use crate::layout::{Edge, LayoutGraphContent, StationKind};
71use crate::metrics::{
72 MAX_JUMP_RISE_16, MetricKind, MetricValue, Metrics, Pitch, Reads, SizeClass, WayClass,
73 passable_clearance_cells, passable_width_cells,
74};
75use crate::stages::AreaLighting;
76
77crate::dw_code! {
78 /// `DW0824`: the graph and the plan do not agree exactly.
79 pub const DW_PLAN_AGREEMENT: DwCode = DwCode::new("DW0824", ExitTier::Build);
80}
81
82crate::dw_code! {
83 /// `DW0825`: a box leaves the kit grid.
84 pub const DW_BOX_OFF_GRID: DwCode = DwCode::new("DW0825", ExitTier::Build);
85}
86
87crate::dw_code! {
88 /// `DW0826`: a box leaves the region.
89 pub const DW_BOX_LEAVES_REGION: DwCode = DwCode::new("DW0826", ExitTier::Build);
90}
91
92crate::dw_code! {
93 /// `DW0827`: two boxes overlap.
94 pub const DW_BOXES_OVERLAP: DwCode = DwCode::new("DW0827", ExitTier::Build);
95}
96
97crate::dw_code! {
98 /// `DW0828`: a seam is not on a shared face.
99 pub const DW_SEAM_NOT_SHARED: DwCode = DwCode::new("DW0828", ExitTier::Build);
100}
101
102/// **How many cells stand between two connected boxes: the wall they share.**
103///
104/// The one number every geometric check in this stage is written against, and
105/// the one an author has to know before the first box goes down. A box is the
106/// **play space** of a place — the cells a body can be in — so the shell is not
107/// inside it: it stands in this gap, and two places that connect leave exactly
108/// this much room for it. Boxes placed flush have no wall to cut a seam through
109/// and `DW0828` refuses them.
110///
111/// It is a constant rather than a literal because the authoring documents state
112/// it: [`PlanBox`]'s schema description carries this value, and
113/// `crates/dsl/tests/v14_site_plan.rs` asserts the exported description against
114/// this constant, so the rule a person reads and the rule the checks enforce
115/// cannot drift apart.
116pub const SHARED_FACE_GAP_CELLS: i64 = 1;
117
118crate::dw_code! {
119 /// `DW0829`: a seam's opening is not a standard, or does not fit.
120 pub const DW_SEAM_OPENING: DwCode = DwCode::new("DW0829", ExitTier::Build);
121}
122
123crate::dw_code! {
124 /// `DW0830`: a stair seam cannot be built at standard pitch.
125 pub const DW_STAIR_PITCH: DwCode = DwCode::new("DW0830", ExitTier::Build);
126}
127
128crate::dw_code! {
129 /// `DW0831`: a drop seam falls outside the drop policy.
130 pub const DW_DROP_POLICY: DwCode = DwCode::new("DW0831", ExitTier::Build);
131}
132
133crate::dw_code! {
134 /// `DW0876`: a seam does not declare a connection this engine builds
135 /// (spec-0053 §6).
136 ///
137 /// **One code, four shapes of one claim** — the claim being that this seam
138 /// states a crossing the derivation can build and the observer can measure:
139 ///
140 /// 1. it declares neither an `opening` nor a `contact`, or both;
141 /// 2. its contact's span leaves the shared face `DW0828` established;
142 /// 3. its contact's span is not **wider than the broadest standard opening**;
143 /// 4. it is a contact on a `stair`, `barred` or `vision` connection.
144 ///
145 /// They are one code rather than four because the author's next action is the
146 /// same in every case — say which kind of hand-off this is and give it a shape
147 /// the engine has — and because a seam exhibiting one of them has no crossing
148 /// for any rule below to judge. It is the shape `DW0830` already carries for a
149 /// stair ("three shapes of one claim") and `DW0829` for an opening ("two halves
150 /// of one claim that the opening is usable").
151 ///
152 /// Shape 3 is the floor that keeps the whole surface honest, and it is
153 /// **structural rather than seeded**: it is derived from the standard opening
154 /// set, so anything at or under it COULD have been a portal, and a doorway
155 /// declared a contact to dodge the standard set is refused by its own width.
156 /// That is the property `CLAUDE.md` demands of an escape hatch — the defect this
157 /// exists to catch is incapable of supplying the hatch's proof obligation.
158 pub const DW_CONTACT: DwCode = DwCode::new("DW0876", ExitTier::Build);
159}
160
161crate::dw_code! {
162 /// `DW0832`: a box violates its node's size class.
163 pub const DW_SIZE_CLASS: DwCode = DwCode::new("DW0832", ExitTier::Build);
164}
165
166crate::dw_code! {
167 /// `DW0833`: a brief identity does not hold.
168 pub const DW_IDENTITY_FALSE: DwCode = DwCode::new("DW0833", ExitTier::Build);
169}
170
171crate::dw_code! {
172 /// `DW0834`: the identity gate binds nothing. Warning — see [`identities`].
173 pub const DW_IDENTITY_EMPTY: DwCode = DwCode::new("DW0834", ExitTier::Build);
174}
175
176crate::dw_code! {
177 /// `DW0835`: a whole-owned volume enters a box.
178 pub const DW_VOLUME_IN_BOX: DwCode = DwCode::new("DW0835", ExitTier::Build);
179}
180
181crate::dw_code! {
182 /// `DW0839`: two placement authorities in one campaign — a `site-plan.json` and
183 /// a non-empty `areas[]` both present.
184 pub const DW_TWO_AUTHORITIES: DwCode = DwCode::new("DW0839", ExitTier::Build);
185}
186
187crate::dw_code! {
188 /// `DW0883`: a box is not placed exactly once (spec-0059 §5). Two shapes of one
189 /// claim: a connected component of the seam graph in which no box is pinned, so
190 /// nothing places it; and a pinned box the packing also reaches, at a different
191 /// corner, so two things place it.
192 pub const DW_UNPLACED: DwCode = DwCode::new("DW0883", ExitTier::Build);
193}
194
195// ---------------------------------------------------------------------------
196// The vocabulary the derivation synthesizes (spec-0049 §5.2)
197// ---------------------------------------------------------------------------
198
199/// **The one area a site-plan campaign has.**
200///
201/// A campaign places its pieces either with `areas[]` or with a site plan, never
202/// both (`DW0839`), so a site-plan campaign has exactly one place for an NPC to
203/// stand in and one area for a quest to belong to. The name is fixed rather than
204/// authored because there is nothing to choose: the site plan is the whole map,
205/// and a second name for it would be a second way to spell one thing.
206pub const SITE_AREA: &str = "area/site";
207
208/// The anchor name the campaign's **entry** stands under.
209///
210/// A *name*, and only a name: what makes this anchor the entry is the declared
211/// entry **role** (spec-0046) the derivation gives it, which is the one thing
212/// the compiler's resolution consults. The spelling survives because a
213/// site-plan campaign's quests and NPCs may address the entry cell like any
214/// other anchor, and `spawn` is the word the rest of the vocabulary already
215/// uses; nothing resolves through it.
216pub const ENTRY_ANCHOR: &str = "spawn";
217
218/// The anchor at a place's floor centre — where quests, NPCs and waves in a
219/// site-plan campaign stand.
220///
221/// `node/near-hall` becomes `anchor/node-near-hall`, and the reshaping is not
222/// cosmetic: a campaign reaches an anchor through [`crate::ids::AnchorId`],
223/// which is `anchor/<kebab>`, so `node/<id>` — spec-0049 §5.2's spelling — is
224/// not a name any document could write. The three families (`node-`, `seam-`,
225/// `unlock-`) are disjoint by their first segment, so no two synthesized
226/// anchors can collide however the graph is named.
227#[must_use]
228pub fn node_anchor(node: &NodeId) -> String {
229 format!("anchor/node-{}", slug(node.0.as_str()))
230}
231
232/// The gate region over a `barred` seam's opening — what an `open-gate` or a
233/// `shortcut` names.
234#[must_use]
235pub fn seam_anchor(edge: &EdgeId) -> String {
236 format!("anchor/seam-{}", slug(edge.0.as_str()))
237}
238
239/// The anchor on the openable side of a one-sided `barred` seam, where a
240/// shortcut's far-side affordance stands.
241#[must_use]
242pub fn seam_unlock_anchor(edge: &EdgeId) -> String {
243 format!("anchor/unlock-{}", slug(edge.0.as_str()))
244}
245
246/// The part of an id after its kind prefix.
247fn slug(id: &str) -> &str {
248 id.split_once('/').map_or(id, |(_, rest)| rest)
249}
250
251/// **What a sealed `barred` seam stands in until content opens it.**
252///
253/// One definition, here rather than in the derivation that lays it, for the same
254/// structural reason the metrics table owns the nav model's constants: two
255/// parties need this block and they need the *same* one. The derivation writes it
256/// into the gate region and declares it on the synthesized gate anchor; every
257/// verb that needs a gate's fill block — `close-gate`, a `shortcut`'s clear, a
258/// `timed-gate`'s clock — asks [`synthesized_gate_block`] whether this campaign
259/// declares one. A copy in each place would be an agreement rather than a fact.
260pub const SEAM_BAR: &str = "minecraft:iron_bars";
261
262/// The fill block a **synthesized** gate anchor declares, or `None` when `anchor`
263/// is not one of this campaign's derived seam gates.
264///
265/// This exists because `DW0343`'s question — *can the compiler fill and clear
266/// this gate?* — used to be answered by one instrument only, the prefab registry,
267/// and a derived world has no prefab. The answer came back honest and about a
268/// smaller world than the campaign has: a `shortcut` naming the very
269/// `anchor/seam-<edge>` the derivation seals with [`SEAM_BAR`] was refused for
270/// declaring no fill block, while the block sat in the derivation's own
271/// `AnchorSpec::Gate`. Nothing was red, because the check was refusing content.
272///
273/// `None` for a campaign with no site plan, and for any anchor the derivation
274/// does not synthesize — those are the prefab registry's to answer for, and this
275/// function never overrides it.
276#[must_use]
277pub fn synthesized_gate_block(c: &Campaign, anchor: &str) -> Option<&'static str> {
278 // Asks the ONE kind authority rather than re-walking the edges, and that is
279 // the whole repair: this used to enumerate `Edge::Barred` alone, so a
280 // `close-gate`, `shortcut` or `timed-gate` naming a **gate station**
281 // (spec-0052) would have been refused by `DW0343` for declaring no fill
282 // block — a refusal whose message says "declare the gate on an anchor of a
283 // piece an area binds", which a site-plan campaign cannot do at all
284 // (`DW0839` refuses a campaign that carries both `areas[]` and a plan).
285 // A narrow binding on the general mechanism, reading as a missing feature.
286 matches!(
287 synthesized_anchor_kinds(c).get(anchor),
288 Some(StationKind::Gate)
289 )
290 .then_some(SEAM_BAR)
291}
292
293/// **Every anchor a site-plan campaign's blockout provides, and what SHAPE each
294/// one is** — the single authority behind [`synthesized_anchors`].
295///
296/// The kind travels with the name because a kind is a property of the **anchor**,
297/// not of the verb that first needed one: `synthesized_gate_block` needed to know
298/// whether a name was a gate and answered by privately re-walking the edges, and
299/// a second consumer wanting the same fact would have re-walked them again. One
300/// function answers it, and everything that needs a shape asks here.
301///
302/// The mapping, and it is total:
303///
304/// * [`ENTRY_ANCHOR`] and every `anchor/node-…` — [`StationKind::Point`], the
305/// floor centre a body stands on.
306/// * every `anchor/unlock-…` — [`StationKind::Point`], where the shortcut's
307/// far-side affordance stands.
308/// * every `anchor/seam-…` — [`StationKind::Gate`], the region the derivation
309/// fills with [`SEAM_BAR`] and content opens.
310/// * every declared station — the kind its node declared (spec-0052 §3).
311///
312/// Empty for a campaign with no site plan — it has prefabs instead, and their
313/// metadata is the authority.
314#[must_use]
315pub fn synthesized_anchor_kinds(c: &Campaign) -> BTreeMap<String, StationKind> {
316 let mut out: BTreeMap<String, StationKind> = BTreeMap::new();
317 if c.site_plan.is_none() {
318 return out;
319 }
320 let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
321 return out; // `DW0824` refused the plan; there is nothing to name.
322 };
323 out.insert(ENTRY_ANCHOR.to_string(), StationKind::Point);
324 for n in &graph.nodes {
325 out.insert(node_anchor(&n.id), StationKind::Point);
326 // A station whose name collides with a synthesized one is `DW0869`, and
327 // one that collides with another station is `DW0870`; both are errors,
328 // so this insert never silently reinterprets a name a campaign builds
329 // with. Inserting anyway keeps the set EXACT for the refused document
330 // too, which is what lets the kind check name the declared kind rather
331 // than a shape the author did not write.
332 for s in &n.stations {
333 out.insert(s.anchor.as_str().to_string(), s.kind);
334 }
335 }
336 for e in &graph.edges {
337 let Edge::Barred { id, opens_from, .. } = e else {
338 continue;
339 };
340 out.insert(seam_anchor(id), StationKind::Gate);
341 if !matches!(opens_from, crate::layout::OpensFrom::Either) {
342 out.insert(seam_unlock_anchor(id), StationKind::Point);
343 }
344 }
345 out
346}
347
348/// **Every anchor a site-plan campaign's blockout provides**, derived from the
349/// documents alone.
350///
351/// One authority, and that is why it lives here rather than in the derivation
352/// that places them: validation resolves a campaign's anchor references against
353/// this set, the derivation creates exactly these anchors, and a name that
354/// validated could therefore never fail to exist at build time. Two functions
355/// agreeing about the spelling is the drift this one removes.
356///
357/// Empty for a campaign with no site plan — it has prefabs instead, and their
358/// metadata is the authority.
359#[must_use]
360pub fn synthesized_anchors(c: &Campaign) -> BTreeSet<String> {
361 // The names ARE the keys of the kind table, taken rather than re-derived:
362 // two functions walking the graph and agreeing about the spelling is the
363 // exact drift this module's note exists to remove, and a station made the
364 // walk long enough that a second copy would eventually diverge.
365 synthesized_anchor_kinds(c).into_keys().collect()
366}
367
368/// **The synthesized names one PLACE owes** (spec-0050 §6) — the subset of
369/// [`synthesized_anchors`] whose bearer is this box.
370///
371/// Its own `anchor/node-…`; [`ENTRY_ANCHOR`] when it is the entry node; each
372/// `anchor/unlock-…` whose `opens_from` side it is — the side the derivation
373/// stands that affordance in. A gate region (`anchor/seam-…`) is **never** owed:
374/// it is whole fabric, standing in a party plane the piece does not own.
375///
376/// Here rather than in `crate::detailplan` because the answer is a fact about
377/// the graph, and [`synthesized_anchors`] is the one authority for what a
378/// site-plan campaign provides — a second module deciding which names belong to
379/// which place is exactly the two-functions-agreeing-about-spelling drift that
380/// note exists to remove.
381///
382/// `crates/delvec/tests/blockout.rs`'s
383/// `the_owed_anchors_partition_the_synthesized_set` proves the two PARTITION
384/// rather than merely overlap: every synthesized name is owed by exactly one
385/// place or is a gate region no place owes. A name in neither would be one a
386/// campaign resolves and no piece is ever asked for; a name in both would be two
387/// pieces claiming one anchor.
388///
389/// Empty for a campaign with no site plan, and for a node the graph does not
390/// have.
391#[must_use]
392pub fn owed_anchors(c: &Campaign, node: &NodeId) -> BTreeSet<String> {
393 let mut out: BTreeSet<String> = BTreeSet::new();
394 if c.site_plan.is_none() {
395 return out;
396 }
397 let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
398 return out; // `DW0824` refused the plan; there is nothing to name.
399 };
400 let Some(n) = graph.nodes.iter().find(|n| &n.id == node) else {
401 return out; // `DW0842` names a row whose place the graph does not have.
402 };
403 out.insert(node_anchor(node));
404 if &graph.entry == node {
405 out.insert(ENTRY_ANCHOR.to_string());
406 }
407 // Every station of this node (spec-0052 §6). The owed set grows **upstream**,
408 // and that one widening is what carries the whole binding chain: the
409 // `detail-plan` `anchors` map must now bind each of them, and it still
410 // refuses every key outside this set, so a binding cannot invent vocabulary
411 // and a typo cannot pass as intent.
412 for s in &n.stations {
413 out.insert(s.anchor.as_str().to_string());
414 }
415 for e in &graph.edges {
416 let Edge::Barred { id, opens_from, .. } = e else {
417 continue;
418 };
419 let side = match opens_from {
420 crate::layout::OpensFrom::A => e.a(),
421 crate::layout::OpensFrom::B => e.b(),
422 crate::layout::OpensFrom::Either => continue,
423 };
424 if side == node {
425 out.insert(seam_unlock_anchor(id));
426 }
427 }
428 out
429}
430
431// ---------------------------------------------------------------------------
432// The document (spec-0049 §4.1)
433// ---------------------------------------------------------------------------
434
435/// The `site-plan` stage document's payload: the geometric embedding of the
436/// layout graph, and the whole map's design of record.
437#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
438#[serde(deny_unknown_fields)]
439pub struct SitePlanContent {
440 /// **The whole map's one region, in world coordinates.**
441 ///
442 /// Required, with no way to omit it and no way to derive it: the schema has
443 /// no "compute this from the boxes" spelling, so extent-flows-up is
444 /// unrepresentable rather than merely forbidden. The number comes from the
445 /// geometry brief and the identities hold the plan to it.
446 ///
447 /// The water plane is deliberately **not** site-plan surface: `horizon:
448 /// ocean` in the stage-1 world document already fixes sea level, and the
449 /// plan reads that single authority rather than restating it.
450 pub region: WorldBox,
451 /// Named ground planes the boxes stand on.
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub datums: Vec<Datum>,
454 /// **Exactly one box per graph node** (`DW0824`).
455 pub boxes: Vec<PlanBox>,
456 /// **Exactly one seam per traversal edge** (`DW0824`). A `vision` edge
457 /// carries a [`Sightline`] instead — see [`Sightline`] for why.
458 pub seams: Vec<Seam>,
459 /// The mass the WHOLE owns: the mountain a cave system is inside, the ground
460 /// under a village, the sky a silhouette needs kept empty.
461 #[serde(default, skip_serializing_if = "Vec::is_empty")]
462 pub volumes: Vec<Volume>,
463 /// The guarded comparisons binding this plan to the geometry brief's facts.
464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub identities: Vec<Identity>,
466 /// One per `vision` edge (`DW0824`).
467 #[serde(default, skip_serializing_if = "Vec::is_empty")]
468 pub sightlines: Vec<Sightline>,
469 /// The named exterior vantages the walk judges the silhouette from. Optional;
470 /// a plan with zero views has that zero stated in the binding line.
471 #[serde(default, skip_serializing_if = "Vec::is_empty")]
472 pub views: Vec<View>,
473 /// One lighting setting applied to every enclosed box, so a blockout
474 /// interior is walkable at night without per-box surface.
475 ///
476 /// **The engine's existing object**, not a twin of it: [`AreaLighting`] is
477 /// already "which fixture, to what light level", and the relight pass that
478 /// consumes it is the same pass either way. A second two-field struct here
479 /// would be the private-copy defect `CLAUDE.md` names, and it would fork the
480 /// range check the moment one of them grew a third field.
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub lighting: Option<AreaLighting>,
483}
484
485/// A box of world cells: its low corner and its extent, in blocks.
486///
487/// The cells are `min[i] ..= min[i] + extent[i] - 1` on each axis. The extent is
488/// [`NonZeroU32`] rather than a `u32` a check refuses: a zero-extent region is
489/// not a small region, it is a document that does not describe a volume, and the
490/// schema says so (`minimum: 1`) so the parse refuses it as an ordinary
491/// `DW0100`. One fewer diagnostic to write and one fewer to forget.
492#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
493#[serde(deny_unknown_fields)]
494pub struct WorldBox {
495 /// Low corner `[x, y, z]`, in world coordinates.
496 pub min: [i64; 3],
497 /// Extent `[dx, dy, dz]`, in blocks.
498 pub extent: [NonZeroU32; 3],
499}
500
501impl WorldBox {
502 /// The high corner (inclusive).
503 #[must_use]
504 pub fn max(&self) -> [i64; 3] {
505 [
506 self.min[0] + i64::from(self.extent[0].get()) - 1,
507 self.min[1] + i64::from(self.extent[1].get()) - 1,
508 self.min[2] + i64::from(self.extent[2].get()) - 1,
509 ]
510 }
511}
512
513/// A named ground plane a box's floor sits on.
514#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
515#[serde(deny_unknown_fields)]
516pub struct Datum {
517 /// Datum id (`datum/<kebab>`), unique within the plan.
518 pub id: DatumId,
519 /// The world `y` of the walk plane.
520 pub y: i64,
521 /// What this plane is, for a reader of the plan.
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub note: Option<String>,
524}
525
526/// Where a place's walk plane is.
527///
528/// Two spellings of one number, and the second is not redundant: a plane several
529/// places stand on is named once as a [`Datum`] and moved once, while a place
530/// that stands alone at its own height has no plane to name. An `identities[]`
531/// entry can only bind to a *named* one, which is the pressure toward naming.
532#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
533#[serde(rename_all = "kebab-case", deny_unknown_fields)]
534pub enum Floor {
535 /// A plane the plan names (`DW0112` if the plan declares no such datum).
536 Datum(DatumId),
537 /// A world `y` this place alone stands at.
538 Y(i64),
539}
540
541/// What closes a place overhead.
542#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
543#[serde(rename_all = "kebab-case", deny_unknown_fields)]
544pub enum Ceiling {
545 /// Cells of headroom over the walk plane. A body's feet are at the floor and
546 /// the ceiling course sits at `floor + clearance`.
547 Clearance(NonZeroU32),
548 /// A sky-open place — a courtyard, a shore, a summit.
549 ///
550 /// The plan claims the ground and its size class's own minimum clearance,
551 /// and **nothing above that**: an open place is precisely one that makes no
552 /// claim on the air over it, so a `clearance` volume above a courtyard is
553 /// the whole reserving sky rather than two authorities over one cell.
554 Open,
555}
556
557/// One place, embedded: a footprint standing on a plane.
558///
559/// **A box is a plan, not a prism.** Its `min`/`extent` are the two horizontal
560/// axes and its vertical position is [`PlanBox::floor`] — one authority for the
561/// plane, where a `y` inside `min` beside a declared floor would have been two
562/// numbers with no rule about which the derivation believes.
563///
564/// **A box is the PLAY SPACE, and connected boxes are separated by exactly one
565/// cell.** `extent` is the interior a body can stand in; the shell the blockout
566/// derivation builds is not inside it. That shell stands in the one-cell gap
567/// between two neighbours, and on the course under the floor and over the
568/// ceiling. So two places that connect are placed one cell apart on the face
569/// they share — that cell is the wall they have in common, written once — and
570/// two boxes placed flush have no wall for a seam to be cut through, which
571/// `DW0828` refuses. Worked: a box at `min: [4, 4]` with `extent: [4, 4]`
572/// occupies x 4..7, so its eastern neighbour's `min` x is 9, never 8.
573///
574/// Two consequences follow, and they are what make the checks say what they look
575/// like they say: the size-class ladder judges `extent` directly (`DW0832`),
576/// its smallest rung `4 × 4` being exactly one kit quantum; and a plan never
577/// states a wall's thickness anywhere, because the gap is where the wall is.
578#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
579#[serde(deny_unknown_fields)]
580pub struct PlanBox {
581 /// The graph node this box embeds.
582 ///
583 /// **The ordering tooth, at the type level**: there is no way to write a box
584 /// that does not name a place, so a site plan cannot describe a space the
585 /// layout graph has not declared (spec-0049 §7.1).
586 pub node: NodeId,
587 /// **A pin**: the low corner `[x, z]` in world coordinates, when the author
588 /// chooses where this box stands. Optional (spec-0059 §2): a box with no pin
589 /// stands where the packing puts it — one cell beyond the face of the box
590 /// its first seam in document order hangs it off. At least one box of every
591 /// connected component of the seam graph is pinned, or nothing places the
592 /// component (`DW0883`); a pinned box the packing also reaches at a
593 /// different corner is refused naming both (`DW0883`). Two horizontal
594 /// numbers, never three — the vertical position is `floor`.
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub min: Option<[i64; 2]>,
597 /// Interior footprint `[dx, dz]`, in blocks, on the kit grid (`DW0825`).
598 /// Two horizontal numbers, never three — the vertical size is `ceiling`.
599 ///
600 /// **This is play space, not the building.** The box covers `min` to
601 /// `min + extent - 1` inclusive, and the walls stand outside it, in the
602 /// one-cell gap that separates connected places (`DW0828`).
603 pub extent: [NonZeroU32; 2],
604 /// The walk plane.
605 pub floor: Floor,
606 /// What closes it overhead.
607 pub ceiling: Ceiling,
608 /// **The sky this place stands under from the first tick** (spec-0080
609 /// §3.2): one of `world.atmospheres[]`, painted at world setup over the
610 /// box's play space grown as far as the client's biome blend reads — the
611 /// same capability `areas[].atmosphere` is, on the
612 /// other class of place with a world box. Absent: the horizon's biome.
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub atmosphere: Option<crate::ids::AtmosphereId>,
615}
616
617/// Which side of a box a seam sits on.
618///
619/// The engine's existing face vocabulary — the same six names a prefab's face
620/// contract writes (`compiler::faces`), so a reader who knows one knows the
621/// other and no translation table exists to disagree with itself.
622#[derive(
623 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
624)]
625#[serde(rename_all = "kebab-case")]
626pub enum Face {
627 /// `+x`.
628 East,
629 /// `-x`.
630 West,
631 /// `+y`.
632 Up,
633 /// `-y`.
634 Down,
635 /// `+z`.
636 South,
637 /// `-z`.
638 North,
639}
640
641impl Face {
642 /// The unit vector this face points along.
643 #[must_use]
644 pub fn vector(self) -> [i64; 3] {
645 match self {
646 Face::East => [1, 0, 0],
647 Face::West => [-1, 0, 0],
648 Face::Up => [0, 1, 0],
649 Face::Down => [0, -1, 0],
650 Face::South => [0, 0, 1],
651 Face::North => [0, 0, -1],
652 }
653 }
654
655 /// True for a face whose plane is horizontal (a floor or a ceiling).
656 #[must_use]
657 pub fn is_horizontal_plane(self) -> bool {
658 matches!(self, Face::Up | Face::Down)
659 }
660
661 /// The name a refusal prints.
662 #[must_use]
663 pub fn as_str(self) -> &'static str {
664 match self {
665 Face::East => "east",
666 Face::West => "west",
667 Face::Up => "up",
668 Face::Down => "down",
669 Face::South => "south",
670 Face::North => "north",
671 }
672 }
673}
674
675/// A crossing's position on one box's face, from that box's own low corner.
676///
677/// One integer on a wall face (cells along the face's horizontal axis), a pair
678/// through a floor or ceiling (cells along `x` and `z`). Untagged, so the
679/// document writes `"at": 2` or `"at": [2, 3]`; the face decides which shape is
680/// a position and the other is `DW0828`.
681#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
682#[serde(untagged)]
683pub enum Offset {
684 /// Cells along a vertical face's horizontal axis.
685 Along(i64),
686 /// Cells along `x` and `z` on a horizontal face.
687 Plane([i64; 2]),
688}
689
690/// One traversal edge, allocated: an opening on a face the two boxes share.
691///
692/// The seam carries **no rise** and **no sill**. A rise is `floor(b) − floor(a)`, which the plan
693/// has already stated by putting the two places where it put them; a second
694/// declaration of it could only ever agree or be a refusal teaching nothing the
695/// datums did not already say. `DW0830` and `DW0831` judge the derived number,
696/// and the stage-5 observer of the built bytes judges the realized one against
697/// the same derivation rather than against an author's copy of it.
698#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
699#[serde(deny_unknown_fields)]
700pub struct Seam {
701 /// The graph edge this seam allocates.
702 pub edge: EdgeId,
703 /// Which face **of the edge's `a` box** the seam sits on. The `b` box is the
704 /// neighbour across it (`DW0828`).
705 pub face: Face,
706 /// Where the crossing sits on **`a`'s** face: an offset from `a`'s own low
707 /// corner along the face, never a world coordinate (spec-0059 §2).
708 ///
709 /// * on a vertical face (`east`/`west`/`north`/`south`) — one integer, cells
710 /// along the face's horizontal axis (`z` for east/west, `x` for
711 /// north/south). The **sill is not written**: it is
712 /// `max(floor(a), floor(b))`, which the plan has already stated.
713 /// * on a horizontal face (`up`/`down`) — `[dx, dz]`, cells along `x` and
714 /// `z`.
715 ///
716 /// Omitted, the crossing is **centred** on the face: `(extent - width) div
717 /// 2`. An offset that leaves the face, or of the wrong shape for the face,
718 /// is `DW0828`.
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub at: Option<Offset>,
721 /// Where the same crossing sits on **`b`'s** face — the same offset, taken
722 /// from `b`'s own low corner. Omitted, centred on `b`'s face. The packing
723 /// places `b` so that the two agree: `corner(b) = corner(a) + at - meets`
724 /// along the face (spec-0059 §3). On a seam whose two boxes both already
725 /// stand, the two must name the same cells (`DW0828`).
726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub meets: Option<Offset>,
728 /// **A PORTAL**: a named opening from the metrics table's standard set
729 /// (`DW0812` on a name the table does not define, `DW0829` on one that does
730 /// not fit). A body crosses at exactly the cells `at` and this standard
731 /// allocate.
732 ///
733 /// **Exactly one of this and [`Seam::contact`]** (`DW0876`).
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub opening: Option<String>,
736 /// **A CONTACT**: the two places simply meet along a front, rather than
737 /// through a doorway (spec-0053 §4).
738 ///
739 /// **Exactly one of this and [`Seam::opening`]** (`DW0876`).
740 ///
741 /// The width of a front where two places meet is a fact of those two boxes'
742 /// shared face — per-campaign geometry, continuous — so it is never a named
743 /// standard. A table that enumerated it would gain a new entry per campaign,
744 /// which is the size ladder's own failure mode reproduced in the opening
745 /// set: an `opening.gate-front` of 21×4 is content wearing a standard's
746 /// clothes (spec-0053 §7).
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub contact: Option<Contact>,
749 /// Which of the edge's two boxes hosts the stair massing. Required on a
750 /// `stair` edge and refused on any other (`DW0830`, `DW0824`).
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub stair_in: Option<NodeId>,
753}
754
755/// A mass the whole itself owns.
756#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
757#[serde(deny_unknown_fields)]
758pub struct Volume {
759 /// Volume id (`volume/<kebab>`), unique within the plan.
760 pub id: VolumeId,
761 /// The cells it covers.
762 pub region: WorldBox,
763 /// What the whole is doing with them.
764 pub role: VolumeRole,
765 /// What this mass is, for a reader of the plan.
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub note: Option<String>,
768}
769
770/// What a whole-owned volume is for.
771#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772#[serde(rename_all = "kebab-case")]
773pub enum VolumeRole {
774 /// Solid mass the places are cut into — the mountain around a cave system.
775 Massif,
776 /// The ground the places stand on.
777 Ground,
778 /// Air the whole keeps empty — the sky a silhouette needs, the drop a
779 /// vista looks over.
780 Clearance,
781}
782
783/// One guarded comparison binding the plan to a fact of the written brief.
784#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
785#[serde(deny_unknown_fields)]
786pub struct Identity {
787 /// The brief fact this holds the plan to (`DW0112` if the brief has no such
788 /// fact).
789 pub fact: FactId,
790 /// What is measured off the plan.
791 pub measure: Measure,
792 /// How the measurement must stand to the fact's value.
793 pub cmp: Cmp,
794}
795
796/// What an identity measures off the plan.
797///
798/// A small **fixed** vocabulary, spelled as a tagged union rather than as the
799/// spec's `box(<node>).extent.x` string. The vocabulary is exactly the spec's
800/// five; what changes is that a measure is parsed by serde instead of by a
801/// grammar this module would have had to write, own and document — so an
802/// unknown measure is an ordinary `DW0100`, a node it names is checked like
803/// every other reference, and the growth the spec's marked judgement predicts is
804/// a variant rather than a second escaping rule. §9 records the departure.
805#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
806#[serde(tag = "of", rename_all = "kebab-case", deny_unknown_fields)]
807pub enum Measure {
808 /// The whole region's extent on one axis, in blocks.
809 RegionExtent {
810 /// Which axis.
811 axis: Axis,
812 },
813 /// One place's footprint on one horizontal axis, in blocks.
814 BoxExtent {
815 /// The place.
816 node: NodeId,
817 /// Which horizontal axis.
818 axis: PlanAxis,
819 },
820 /// One place's headroom over its walk plane, in blocks. A sky-open place
821 /// measures its size class's own minimum clearance — the least air the
822 /// ladder says such a place has.
823 BoxHeight {
824 /// The place.
825 node: NodeId,
826 },
827 /// The horizontal distance between two places' footprint centres, in blocks.
828 /// Euclidean on `x`/`z`; the standoff a brief states between two things.
829 DistanceXz {
830 /// One place.
831 from: NodeId,
832 /// The other.
833 to: NodeId,
834 },
835 /// A named ground plane's world `y`.
836 DatumY {
837 /// The plane.
838 datum: DatumId,
839 },
840}
841
842/// One of the three world axes.
843#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
844#[serde(rename_all = "kebab-case")]
845pub enum Axis {
846 /// East–west.
847 X,
848 /// Up–down.
849 Y,
850 /// North–south.
851 Z,
852}
853
854/// One of the two horizontal axes.
855///
856/// A separate type from [`Axis`] rather than a `y` some check refuses: a box is
857/// a footprint, so its extent has no `y` to ask about, and an unrepresentable
858/// state needs no diagnostic to police it.
859#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
860#[serde(rename_all = "kebab-case")]
861pub enum PlanAxis {
862 /// East–west.
863 X,
864 /// North–south.
865 Z,
866}
867
868/// How a measurement must stand to its fact's value.
869#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
870#[serde(rename_all = "kebab-case")]
871pub enum Cmp {
872 /// Exactly.
873 Eq,
874 /// Strictly under.
875 Lt,
876 /// At most.
877 Le,
878 /// Strictly over.
879 Gt,
880 /// At least.
881 Ge,
882}
883
884impl Cmp {
885 fn holds(self, measured: f64, fact: f64) -> bool {
886 match self {
887 Cmp::Eq => (measured - fact).abs() < 1e-9,
888 Cmp::Lt => measured < fact,
889 Cmp::Le => measured <= fact,
890 Cmp::Gt => measured > fact,
891 Cmp::Ge => measured >= fact,
892 }
893 }
894
895 fn as_str(self) -> &'static str {
896 match self {
897 Cmp::Eq => "exactly",
898 Cmp::Lt => "under",
899 Cmp::Le => "at most",
900 Cmp::Gt => "over",
901 Cmp::Ge => "at least",
902 }
903 }
904}
905
906/// A `vision` edge, embedded: the segment the stage-5 battery walks.
907///
908/// A vision edge gets a sightline rather than a seam because a vista's two ends
909/// are routinely not adjacent — a bell tower seen from a shore shares no face
910/// with it — so the seam construct cannot state the one thing a vision edge
911/// asserts. A window between neighbours is simply a short sightline
912/// (spec-0049 §4.4).
913#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
914#[serde(deny_unknown_fields)]
915pub struct Sightline {
916 /// The `vision` edge this embeds.
917 pub edge: EdgeId,
918 /// The eye end, in world coordinates — inside the edge's `a` box
919 /// (`DW0824`).
920 pub from: [i64; 3],
921 /// The seen end — inside the edge's `b` box (`DW0824`).
922 pub to: [i64; 3],
923}
924
925/// A named exterior vantage the walk judges the silhouette from.
926#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
927#[serde(deny_unknown_fields)]
928pub struct View {
929 /// View id (`view/<kebab>`), unique within the plan.
930 pub id: ViewId,
931 /// Where the eye stands, in world coordinates.
932 pub eye: [i64; 3],
933 /// What it looks at.
934 pub look_at: [i64; 3],
935 /// What this view is for.
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub note: Option<String>,
938}
939
940// ---------------------------------------------------------------------------
941// Resolution — the plan read once, so no check re-derives a number
942// ---------------------------------------------------------------------------
943
944/// One box with everything the checks below need already worked out.
945///
946/// **The model every geometric check depends on** is stated where an author
947/// reads it — on [`PlanBox`], whose schema description carries it — and the
948/// number itself is [`SHARED_FACE_GAP_CELLS`]. In short: a box is the **play
949/// space** of a place, the shell stands in the one-cell gap between two
950/// neighbours, `extent` is therefore the interior footprint the size-class
951/// ladder judges directly (`DW0832`), and two connected places sit exactly
952/// [`SHARED_FACE_GAP_CELLS`] apart on the face they share (`DW0828`).
953#[derive(Debug, Clone)]
954struct Placed<'a> {
955 index: usize,
956 plan: &'a PlanBox,
957 /// Footprint, inclusive: `[x0, x1, z0, z1]`.
958 foot: [i64; 4],
959 /// The walk plane.
960 floor: i64,
961 /// Cells of headroom over the walk plane, or `None` when the place is
962 /// sky-open and its classification did not resolve.
963 clearance: Option<u32>,
964 /// How the place is classified, when the name resolved.
965 class: Option<PlaceClass>,
966 /// How its corner was obtained (spec-0059 §3).
967 by: Provenance,
968}
969
970/// **How a place is classified** — the two kinds of standard a box is judged
971/// against (spec-0053 §3).
972///
973/// The classification belongs to the PLACE, so it is one field of two kinds
974/// rather than two fields. Written as a second `Option<WayClass>` beside the
975/// first, every consumer would have had to remember to look at both, and the
976/// one that forgot would silently judge a road against nothing — which is the
977/// state this whole surface exists to end, reintroduced one layer down.
978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979enum PlaceClass {
980 /// A rung of the size ladder: both horizontal extents bounded.
981 Size(SizeClass),
982 /// A way: the cross-section bounded, the run free.
983 Way(WayClass),
984}
985
986impl PlaceClass {
987 /// The least interior clearance the class demands.
988 ///
989 /// The one question both kinds answer identically, which is why a sky-open
990 /// box needs no arm: an open place claims exactly its class's minimum
991 /// headroom and nothing above it, and that sentence is true of a road as it
992 /// is of a hall.
993 fn min_clearance(self) -> u32 {
994 match self {
995 PlaceClass::Size(c) => c.min_clearance,
996 PlaceClass::Way(w) => w.min_clearance,
997 }
998 }
999}
1000
1001impl Placed<'_> {
1002 fn x0(&self) -> i64 {
1003 self.foot[0]
1004 }
1005 fn x1(&self) -> i64 {
1006 self.foot[1]
1007 }
1008 fn z0(&self) -> i64 {
1009 self.foot[2]
1010 }
1011 fn z1(&self) -> i64 {
1012 self.foot[3]
1013 }
1014
1015 /// The inclusive vertical span of the play space, when it is bounded.
1016 fn y_span(&self) -> Option<(i64, i64)> {
1017 let c = i64::from(self.clearance?);
1018 Some((self.floor, self.floor + c - 1))
1019 }
1020
1021 /// The centre of the footprint, in blocks.
1022 fn centre_xz(&self) -> (f64, f64) {
1023 (
1024 (self.x0() as f64 + self.x1() as f64) / 2.0,
1025 (self.z0() as f64 + self.z1() as f64) / 2.0,
1026 )
1027 }
1028}
1029
1030// ---------------------------------------------------------------------------
1031// The resolved plan, in world cells — ONE authority, three readers
1032// ---------------------------------------------------------------------------
1033
1034/// One place, resolved into world cells: the play space the plan gives it.
1035///
1036/// Public because three readers need the same answer and two of them are in
1037/// another crate: the stage-4 checks here, the **blockout derivation** that
1038/// builds the mass, and the **stage-5 battery** that judges the built bytes
1039/// against the plan. Two of those computing "where is this box" independently is
1040/// how a builder and its observer come to agree about a world neither of them
1041/// describes.
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct PlacedBox {
1044 /// The place this embeds.
1045 pub node: NodeId,
1046 /// Inclusive footprint `[x0, x1, z0, z1]`.
1047 pub foot: [i64; 4],
1048 /// The walk plane's world `y`.
1049 pub floor: i64,
1050 /// Cells of headroom over the walk plane.
1051 pub clearance: u32,
1052 /// True when the plan declared no ceiling — a courtyard, a shore, a summit.
1053 /// The place still claims its size class's own minimum headroom (which is
1054 /// what [`PlacedBox::clearance`] holds); what it makes no claim on is the
1055 /// air above that.
1056 pub open: bool,
1057}
1058
1059impl PlacedBox {
1060 /// The play space's inclusive world AABB.
1061 #[must_use]
1062 pub fn space(&self) -> ([i64; 3], [i64; 3]) {
1063 (
1064 [self.foot[0], self.floor, self.foot[2]],
1065 [
1066 self.foot[1],
1067 self.floor + i64::from(self.clearance) - 1,
1068 self.foot[3],
1069 ],
1070 )
1071 }
1072
1073 /// The floor centre — where a body seated in this place stands.
1074 #[must_use]
1075 pub fn centre(&self) -> [i64; 3] {
1076 [
1077 (self.foot[0] + self.foot[1]) / 2,
1078 self.floor,
1079 (self.foot[2] + self.foot[3]) / 2,
1080 ]
1081 }
1082}
1083
1084/// One connection, resolved into world cells: the wall the two places share and
1085/// the hole the plan cut in it.
1086#[derive(Debug, Clone, PartialEq, Eq)]
1087pub struct PlacedSeam {
1088 /// The connection this allocates.
1089 pub edge: EdgeId,
1090 /// Its class, as the graph spells it.
1091 pub class: &'static str,
1092 /// The `a` end.
1093 pub a: NodeId,
1094 /// The `b` end.
1095 pub b: NodeId,
1096 /// Which face **of `a`** the seam sits on.
1097 pub face: Face,
1098 /// The axis the shared wall is flat in: 0 = x, 1 = y, 2 = z.
1099 pub normal_axis: usize,
1100 /// The wall's coordinate on that axis — one cell thick, so one number.
1101 pub plane: i64,
1102 /// The opening's inclusive world AABB (flat in [`Self::normal_axis`]).
1103 pub opening: ([i64; 3], [i64; 3]),
1104 /// The whole rectangle the two boxes share on that wall, inclusive.
1105 pub shared: ([i64; 3], [i64; 3]),
1106 /// Which kind of connection this seam allocates (spec-0053 §4). A portal's
1107 /// `opening` is a standard's rectangle; a contact's is its span.
1108 pub crossing: Crossing,
1109 /// `floor(b) − floor(a)`, derived — never authored (see [`Seam`]).
1110 pub rise: i64,
1111 /// Which place hosts the stair massing, on a `stair`.
1112 pub stair_in: Option<NodeId>,
1113}
1114
1115/// The plan's boxes, resolved by the code the stage-4 checks judge with.
1116///
1117/// A box whose floor names an undeclared datum is **absent** — `DW0112` has
1118/// refused it, and a place with no plane has no cells for any reader to work in.
1119/// A sky-open box whose size class did not resolve is absent for the same reason
1120/// (`DW0812` refused the class, so the plan states no headroom for it at all).
1121#[must_use]
1122pub fn placed_boxes(c: &Campaign, reads: &mut Reads) -> Vec<PlacedBox> {
1123 let (Some(plan), Some(graph)) = (
1124 c.site_plan.as_ref().map(|p| &p.content),
1125 c.layout_graph.as_ref().map(|g| &g.content),
1126 ) else {
1127 return Vec::new();
1128 };
1129 let table = Metrics::table();
1130 let mut sink = Vec::new();
1131 resolve(plan, graph, &table, reads, &mut sink)
1132 .0
1133 .into_iter()
1134 .filter_map(|p| {
1135 Some(PlacedBox {
1136 node: p.plan.node.clone(),
1137 foot: p.foot,
1138 floor: p.floor,
1139 clearance: p.clearance?,
1140 open: matches!(p.plan.ceiling, Ceiling::Open),
1141 })
1142 })
1143 .collect()
1144}
1145
1146/// **The one place a seam's crossing rectangle is computed**, for either kind
1147/// of connection (spec-0053 §4).
1148///
1149/// A portal's rectangle is the named standard's `width × height` anchored at
1150/// `at`. A contact's is its span: `at` plus the declared `extent`, or `at` to
1151/// the far edge of the shared face when no extent is declared.
1152///
1153/// One function rather than one per kind, and one call rather than a copy in
1154/// each reader, because this rectangle is simultaneously the derivation's carve,
1155/// `DW0836`'s allocation, `DW0838`'s allocation set and `DW0877`'s span. Two
1156/// implementations of it would be a plan-time green and a byte-time green about
1157/// two different rectangles, which is the defect `shared_face` already has one
1158/// implementation to prevent.
1159///
1160/// `None` when the seam names an opening the table does not define — `DW0812`
1161/// refused it and there is no rectangle to build or measure.
1162fn crossing_rect(
1163 s: &Seam,
1164 at: [i64; 2],
1165 face: &SharedFace,
1166 table: &Metrics,
1167 reads: &mut Reads,
1168) -> Option<(Crossing, [i64; 2])> {
1169 if s.contact.is_some() {
1170 return Some((Crossing::Contact, contact_extent(s, at, face)));
1171 }
1172 let named = s.opening.as_ref()?;
1173 let entry = table.resolve(MetricKind::Opening, named).ok()?;
1174 match entry.value(reads) {
1175 MetricValue::Opening(o) => {
1176 Some((Crossing::Portal, [i64::from(o.width), i64::from(o.height)]))
1177 }
1178 _ => None,
1179 }
1180}
1181
1182/// **How big a contact's span is** — the one authority, read by
1183/// [`crossing_rect`] and by the refusal that judges it (`DW0876`).
1184///
1185/// A declared `extent` is taken as written. With none declared the span runs
1186/// from `at` to the far edge of the shared face on both axes, which is how a
1187/// contact along the whole of a face is spelled. An `at` already past that edge
1188/// would give a negative extent, so it is clamped to one cell: the rectangle
1189/// stays well-formed and `DW0876` describes it, rather than the arithmetic
1190/// producing a rectangle nothing downstream could reason about.
1191fn contact_extent(s: &Seam, at: [i64; 2], face: &SharedFace) -> [i64; 2] {
1192 match s.contact.as_ref().and_then(|c| c.extent) {
1193 Some(e) => [i64::from(e[0].get()), i64::from(e[1].get())],
1194 None => [(face.u.1 - at[0] + 1).max(1), (face.v.1 - at[1] + 1).max(1)],
1195 }
1196}
1197
1198/// Which axis a face is flat in: 0 = x, 1 = y, 2 = z.
1199#[must_use]
1200pub fn normal_axis_of(face: Face) -> usize {
1201 match face {
1202 Face::East | Face::West => 0,
1203 Face::Up | Face::Down => 1,
1204 Face::South | Face::North => 2,
1205 }
1206}
1207
1208/// A seam's crossing rectangle as an inclusive world AABB, flat in the face's
1209/// normal axis — [`crossing_rect`]'s two numbers put where the world is.
1210///
1211/// Extracted rather than written twice because [`stair_run`] needs the same
1212/// rectangle at validation tier, before any `PlacedSeam` exists.
1213fn crossing_aabb(
1214 s: &Seam,
1215 at: [i64; 2],
1216 face: &SharedFace,
1217 extent: [i64; 2],
1218) -> ([i64; 3], [i64; 3]) {
1219 let normal_axis = normal_axis_of(s.face);
1220 let (u_axis, v_axis) = in_plane_axes(s.face);
1221 let mut lo = [0i64; 3];
1222 let mut hi = [0i64; 3];
1223 lo[normal_axis] = face.plane;
1224 hi[normal_axis] = face.plane;
1225 lo[u_axis] = at[0];
1226 hi[u_axis] = at[0] + extent[0] - 1;
1227 lo[v_axis] = at[1];
1228 hi[v_axis] = at[1] + extent[1] - 1;
1229 (lo, hi)
1230}
1231
1232/// **What a stair costs the box that hosts it** — the one place the geometry of
1233/// a run is worked out, for both the check that refuses a plan (`DW0830`) and
1234/// the derivation that lays the treads.
1235///
1236/// # Why this is one function and not two
1237///
1238/// It was two, and the two disagreed. `DW0830` measured the run against the
1239/// seam's **rise** and against the host's whole **extent**; the derivation
1240/// measures it against the **climb** the opening really asks for and against
1241/// the run the host really has beside the hole. Both readings are defensible
1242/// in isolation and neither is the other, so a plan could pass the check by one
1243/// arithmetic and be refused by the other at build time — the treads then went
1244/// unlaid, and the place they were the only way into came back as `DW0837` with
1245/// nothing pointing at the stair. Found by building: a two-place plan whose
1246/// lower room is entered through a hole in the upper room's floor reached green
1247/// at stage 4 with `needs 8, affords 8` and built no stair at all, because the
1248/// climb to the pierced plane is 7 and the run beside a centred hole is 6.
1249///
1250/// So the rule lives here and both readers call it. A plan that reaches green
1251/// is a plan the derivation can build, by construction rather than by two
1252/// arithmetics agreeing.
1253///
1254/// # What the numbers mean
1255///
1256/// * `climb` — how high the courses must carry a body. Across a **vertical**
1257/// face that is the seam's own sill, because the body stands at the sill and
1258/// steps through; through a **floor or ceiling** it is the pierced plane,
1259/// because the body stands in the hole and steps out beside it. Both come out
1260/// as the floor difference on an ordinary plan and differ exactly where the
1261/// plan puts the sill somewhere other than the far floor.
1262/// * `run_axis` — the horizontal axis the run is spent on. Across a vertical
1263/// face it is that face's normal; through a floor or ceiling the run may go
1264/// either way, so it is the host's longer horizontal axis.
1265/// * `start`/`step` — the stair arrives AT its seam, so course 0 is the one the
1266/// body steps off and the run walks back into the room.
1267/// * `available` — how many cells of that walk the host really has. Across a
1268/// vertical face the run walks the whole footprint. Through a floor or a
1269/// ceiling it leaves along **one** side of the hole, so it is the room on the
1270/// roomier side plus the hole's own width — never the host's whole extent,
1271/// which is a run that only exists if the stair could run both ways at once.
1272///
1273/// `None` where there is no run to lay or judge: a host at or above what the
1274/// stair has to reach (the plan named the higher place, which `DW0830` refuses
1275/// by name), or a hole that is not over this host at all (`DW0828`'s finding).
1276#[must_use]
1277pub fn stair_run(
1278 host_floor: i64,
1279 host_foot: [i64; 4],
1280 normal_axis: usize,
1281 plane: i64,
1282 opening: ([i64; 3], [i64; 3]),
1283) -> Option<StairRun> {
1284 let (olo, ohi) = opening;
1285 let lo = [host_foot[0], host_floor, host_foot[2]];
1286 let hi = [host_foot[1], host_floor, host_foot[3]];
1287 let target = if normal_axis == 1 { plane } else { olo[1] };
1288 let climb = target - host_floor;
1289 if climb <= 0 {
1290 return None;
1291 }
1292 let run_axis = if normal_axis == 1 {
1293 let ex = host_foot[1] - host_foot[0] + 1;
1294 let ez = host_foot[3] - host_foot[2] + 1;
1295 if ex >= ez { 0usize } else { 2 }
1296 } else {
1297 normal_axis
1298 };
1299 let (start, step, available) = if normal_axis == 1 {
1300 let (olo_r, ohi_r) = (
1301 olo[run_axis].max(lo[run_axis]),
1302 ohi[run_axis].min(hi[run_axis]),
1303 );
1304 if olo_r > ohi_r {
1305 return None;
1306 }
1307 let width = ohi_r - olo_r + 1;
1308 let room_lo = olo_r - lo[run_axis];
1309 let room_hi = hi[run_axis] - ohi_r;
1310 if room_lo >= room_hi {
1311 (ohi_r, -1, room_lo + width)
1312 } else {
1313 (olo_r, 1, room_hi + width)
1314 }
1315 } else if plane > hi[run_axis] {
1316 (hi[run_axis], -1, hi[run_axis] - lo[run_axis] + 1)
1317 } else {
1318 (lo[run_axis], 1, hi[run_axis] - lo[run_axis] + 1)
1319 };
1320 Some(StairRun {
1321 climb,
1322 run_axis,
1323 start,
1324 step,
1325 available,
1326 })
1327}
1328
1329/// What a stair's treads cost their host — see [`stair_run`].
1330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1331pub struct StairRun {
1332 /// How high the courses must carry a body, off the host's own walk plane.
1333 pub climb: i64,
1334 /// The horizontal axis the run walks: 0 = x, 2 = z.
1335 pub run_axis: usize,
1336 /// Where course 0 — the one the body steps off — stands on `run_axis`.
1337 pub start: i64,
1338 /// Which way the run walks back into the host: `+1` or `-1`.
1339 pub step: i64,
1340 /// How many cells of that walk the host really affords.
1341 pub available: i64,
1342}
1343
1344/// The run one standard pitch costs a climb, rounded up: a course is a whole
1345/// cell, and a run two-thirds of a cell short is a run the host does not have.
1346#[must_use]
1347pub fn run_of(pitch: &Pitch, climb: i64) -> i64 {
1348 let (span, per) = (climb.abs() * i64::from(pitch.run), i64::from(pitch.rise));
1349 if per == 0 {
1350 span
1351 } else {
1352 span / per + i64::from(span % per != 0)
1353 }
1354}
1355
1356/// **The gentlest standard pitch a climb fits in `available` cells of run**, or
1357/// `None` when the table defines none that does.
1358///
1359/// The walk is over [`Metrics::names_of`] in table order — gentlest first — and
1360/// takes the first that fits. One authority, so the verdict `DW0830` reaches and
1361/// the geometry the derivation lays cannot be about different standards.
1362#[must_use]
1363pub fn gentlest_pitch(
1364 table: &Metrics,
1365 reads: &mut Reads,
1366 climb: i64,
1367 available: i64,
1368) -> Option<Pitch> {
1369 for name in table.names_of(MetricKind::Pitch) {
1370 let Ok(entry) = table.resolve(MetricKind::Pitch, name) else {
1371 continue;
1372 };
1373 let MetricValue::Pitch(p) = entry.value(reads) else {
1374 continue;
1375 };
1376 if p.rise == 0 {
1377 continue;
1378 }
1379 if run_of(p, climb) <= available {
1380 return Some(*p);
1381 }
1382 }
1383 None
1384}
1385
1386/// The standard pitch that costs a climb the LEAST run, and what that run is —
1387/// the number `DW0830`'s refusal quotes, because it is the shortest run any
1388/// standard could do the climb in.
1389#[must_use]
1390pub fn tightest_pitch(
1391 table: &Metrics,
1392 reads: &mut Reads,
1393 climb: i64,
1394) -> Option<(&'static str, i64)> {
1395 let mut best: Option<(&'static str, i64)> = None;
1396 for name in table.names_of(MetricKind::Pitch) {
1397 let Ok(entry) = table.resolve(MetricKind::Pitch, name) else {
1398 continue;
1399 };
1400 let MetricValue::Pitch(p) = entry.value(reads) else {
1401 continue;
1402 };
1403 if p.rise == 0 {
1404 continue;
1405 }
1406 let needed = run_of(p, climb);
1407 if best.is_none_or(|(_, b)| needed < b) {
1408 best = Some((name, needed));
1409 }
1410 }
1411 best
1412}
1413
1414/// The plan's seams, resolved by the code the stage-4 checks judge with.
1415///
1416/// A seam whose face the two boxes do not share, or whose opening the table does
1417/// not define, is **absent**: `DW0828`/`DW0812` refused it, and there is no hole
1418/// for a reader to build or measure.
1419#[must_use]
1420pub fn placed_seams(c: &Campaign, boxes: &[PlacedBox], reads: &mut Reads) -> Vec<PlacedSeam> {
1421 let (Some(plan), Some(graph)) = (
1422 c.site_plan.as_ref().map(|p| &p.content),
1423 c.layout_graph.as_ref().map(|g| &g.content),
1424 ) else {
1425 return Vec::new();
1426 };
1427 let table = Metrics::table();
1428 let by_node: BTreeMap<&str, &PlacedBox> =
1429 boxes.iter().map(|b| (b.node.0.as_str(), b)).collect();
1430 let edges: BTreeMap<&str, &Edge> = graph.edges.iter().map(|e| (e.id().0.as_str(), e)).collect();
1431 // The seam anchors come from the same packing that placed `boxes`: one
1432 // arithmetic, so the derivation and its observer cannot disagree about
1433 // where a hole is.
1434 let mut sink = Vec::new();
1435 let (_, packed) = resolve(plan, graph, &table, reads, &mut sink);
1436 let mut out = Vec::new();
1437 for (i, s) in plan.seams.iter().enumerate() {
1438 let Some(at) = packed.seam_at[i] else {
1439 continue; // the packing refused or could not place this seam
1440 };
1441 let Some(edge) = edges.get(s.edge.0.as_str()) else {
1442 continue;
1443 };
1444 if !edge.has_seam() {
1445 continue;
1446 }
1447 let (Some(a), Some(b)) = (
1448 by_node.get(edge.a().0.as_str()).copied(),
1449 by_node.get(edge.b().0.as_str()).copied(),
1450 ) else {
1451 continue;
1452 };
1453 let Ok(face) = shared_face_of(a, b, s.face) else {
1454 continue;
1455 };
1456 let Some((crossing, extent)) = crossing_rect(s, at, &face, &table, reads) else {
1457 continue;
1458 };
1459 let normal_axis = normal_axis_of(s.face);
1460 // The face's two in-plane axes, in the order `at` names them.
1461 let (u_axis, v_axis) = in_plane_axes(s.face);
1462 let (lo, hi) = crossing_aabb(s, at, &face, extent);
1463 let mut smin = [0i64; 3];
1464 let mut smax = [0i64; 3];
1465 smin[normal_axis] = face.plane;
1466 smax[normal_axis] = face.plane;
1467 smin[u_axis] = face.u.0;
1468 smax[u_axis] = face.u.1;
1469 smin[v_axis] = face.v.0;
1470 smax[v_axis] = face.v.1;
1471 out.push(PlacedSeam {
1472 edge: s.edge.clone(),
1473 class: edge.class(),
1474 a: edge.a().clone(),
1475 b: edge.b().clone(),
1476 face: s.face,
1477 normal_axis,
1478 plane: face.plane,
1479 opening: (lo, hi),
1480 shared: (smin, smax),
1481 crossing,
1482 rise: b.floor - a.floor,
1483 stair_in: s.stair_in.clone(),
1484 });
1485 }
1486 out
1487}
1488
1489/// **A contact**: the span of a shared face along which two places simply meet
1490/// (spec-0053 §4).
1491///
1492/// # What a contact MEANS
1493///
1494/// The boundary is continuous ground. The derivation writes **no wall along the
1495/// span** — and wall as ever outside it — and crossing is legitimate anywhere
1496/// along it the step rule admits. It is not a wide door: `DW0829`'s standard-name
1497/// resolution and sill rule are portal checks and do not apply, because a
1498/// contact has no opening name to resolve and no single sill. Calling a 55-cell
1499/// front a door would make every downstream door check wrong.
1500///
1501/// # What the author allocates and what the engine measures
1502///
1503/// The author allocates **where** the places meet. The engine measures the
1504/// **crossing profile** from assembled bytes — which columns of the span a body
1505/// actually crosses under the step rule — and `DW0877` refuses a contact nothing
1506/// can cross. *"This face is fine"* is never a declaration this engine accepts.
1507///
1508/// Seams stay **allocated, never discovered**: the span is the edge's allocation
1509/// set for `DW0838`, so a crossing outside it is still a refusal.
1510#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1511#[serde(deny_unknown_fields)]
1512pub struct Contact {
1513 /// The span, `[u, v]` in cells on the face's own two in-plane axes, anchored
1514 /// at the seam's `at`.
1515 ///
1516 /// **Omitted, the span runs from `at` to the far edge of the shared face on
1517 /// both axes** — which is how a contact along the whole of a face is
1518 /// written, by putting `at` at the face's low corner. `DW0828`'s refusal
1519 /// prints that corner, so the number an author needs is in the message they
1520 /// would already be reading.
1521 ///
1522 /// There is no `width` standard and no `length` here, and both absences are
1523 /// the design (spec-0053 §7).
1524 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub extent: Option<[NonZeroU32; 2]>,
1526}
1527
1528/// **Which kind of connection a seam allocates** (spec-0053 §4).
1529///
1530/// Carried on the resolved seam rather than re-derived from the authored one at
1531/// each reader, so that the derivation and the byte observer cannot disagree
1532/// about which kind a seam is — the same reason `shared_face` has one
1533/// implementation.
1534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1535#[serde(rename_all = "kebab-case")]
1536pub enum Crossing {
1537 /// A standard opening. Every allocated cell must be passable (`DW0836`).
1538 Portal,
1539 /// A front where two places meet. No wall along the span, and **at least
1540 /// one** passable column of body width somewhere in it (`DW0877`) — not
1541 /// every cell, because a contact is ground rather than a hole and the
1542 /// massing standing on it is content.
1543 Contact,
1544}
1545
1546/// The two world axes a face's `at` names, in that order.
1547fn in_plane_axes(face: Face) -> (usize, usize) {
1548 match face {
1549 // `[along, y]` — `z` for east/west, `x` for north/south.
1550 Face::East | Face::West => (2, 1),
1551 Face::South | Face::North => (0, 1),
1552 // `[x, z]`.
1553 Face::Up | Face::Down => (0, 2),
1554 }
1555}
1556
1557/// A footprint's inclusive span on one WORLD axis (0 = x, 2 = z). Axis 1 has no
1558/// answer here — a footprint is horizontal — and no caller asks for it.
1559fn span(foot: [i64; 4], axis: usize) -> (i64, i64) {
1560 if axis == 0 {
1561 (foot[0], foot[1])
1562 } else {
1563 (foot[2], foot[3])
1564 }
1565}
1566
1567/// Inclusive overlap of two ranges, or `None`.
1568fn overlap(a: (i64, i64), b: (i64, i64)) -> Option<(i64, i64)> {
1569 let lo = a.0.max(b.0);
1570 let hi = a.1.min(b.1);
1571 (lo <= hi).then_some((lo, hi))
1572}
1573
1574/// Is `[lo, hi]` inside `[within_lo, within_hi]`?
1575fn within(r: (i64, i64), w: (i64, i64)) -> bool {
1576 r.0 >= w.0 && r.1 <= w.1
1577}
1578
1579/// The region's inclusive span on one axis.
1580fn region_span(region: &WorldBox, axis: usize) -> (i64, i64) {
1581 (region.min[axis], region.max()[axis])
1582}
1583
1584// ---------------------------------------------------------------------------
1585// The binding ledger's site-plan half
1586// ---------------------------------------------------------------------------
1587
1588/// What a run's site-plan checks bound to.
1589///
1590/// Carried inside [`crate::LayoutBinding`] rather than as a second ledger: the
1591/// map pipeline's documents are counted by one struct with one constructor, so
1592/// the number the CLI prints and the number a diagnostic quotes cannot disagree
1593/// about how many places there are.
1594#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1595pub struct PlanBinding {
1596 /// Places embedded — what `DW0825`, `DW0826` and `DW0832` examine.
1597 pub boxes: usize,
1598 /// Unordered box pairs — what `DW0827` examines. Zero at one box, which is
1599 /// the honest count and not a pass.
1600 pub box_pairs: usize,
1601 /// Connections allocated — what `DW0828` and `DW0829` examine.
1602 pub seams: usize,
1603 /// Of those, seams whose edge is a `stair` — what `DW0830` examines.
1604 pub stair_seams: usize,
1605 /// Of those, seams whose edge is a `drop` — what `DW0831` examines.
1606 pub drop_seams: usize,
1607 /// Named ground planes.
1608 pub datums: usize,
1609 /// Whole-owned masses — what `DW0835` examines.
1610 pub volumes: usize,
1611 /// Guarded comparisons against the brief — what `DW0833` examines.
1612 pub identities: usize,
1613 /// Sightlines embedded.
1614 pub sightlines: usize,
1615 /// Named exterior vantages the walk judges the silhouette from.
1616 pub views: usize,
1617 /// Boxes whose corner the author pinned (spec-0059 §3).
1618 pub pinned: usize,
1619 /// Boxes whose corner a seam derived.
1620 pub derived: usize,
1621 /// Connected components of the seam graph.
1622 pub components: usize,
1623}
1624
1625impl PlanBinding {
1626 /// Count what a campaign's site plan offers the checks.
1627 #[must_use]
1628 pub fn of(c: &Campaign) -> PlanBinding {
1629 let Some(plan) = c.site_plan.as_ref().map(|p| &p.content) else {
1630 return PlanBinding::default();
1631 };
1632 let classes: BTreeMap<&str, &'static str> = c
1633 .layout_graph
1634 .as_ref()
1635 .map(|g| {
1636 g.content
1637 .edges
1638 .iter()
1639 .map(|e| (e.id().0.as_str(), e.class()))
1640 .collect()
1641 })
1642 .unwrap_or_default();
1643 let n = plan.boxes.len();
1644 let (pinned, derived, components) = match c.layout_graph.as_ref() {
1645 Some(g) => {
1646 let table = Metrics::table();
1647 let mut reads = Reads::new();
1648 let mut sink = Vec::new();
1649 let (_, packed) = resolve(plan, &g.content, &table, &mut reads, &mut sink);
1650 (packed.pinned, packed.derived, packed.components)
1651 }
1652 None => (0, 0, 0),
1653 };
1654 PlanBinding {
1655 pinned,
1656 derived,
1657 components,
1658 boxes: n,
1659 box_pairs: n * n.saturating_sub(1) / 2,
1660 seams: plan.seams.len(),
1661 stair_seams: plan
1662 .seams
1663 .iter()
1664 .filter(|s| classes.get(s.edge.0.as_str()) == Some(&"stair"))
1665 .count(),
1666 drop_seams: plan
1667 .seams
1668 .iter()
1669 .filter(|s| classes.get(s.edge.0.as_str()) == Some(&"drop"))
1670 .count(),
1671 datums: plan.datums.len(),
1672 volumes: plan.volumes.len(),
1673 identities: plan.identities.len(),
1674 sightlines: plan.sightlines.len(),
1675 views: plan.views.len(),
1676 }
1677 }
1678
1679 /// The site-plan half of the binding line.
1680 #[must_use]
1681 pub fn line(&self) -> String {
1682 format!(
1683 "site-plan binding: {b} box(es) ({p} pair(s) compared; {pn} pinned, {dv} derived, \
1684 in {cc} component(s)), {s} seam(s) ({st} stair, {sd} drop), {d} datum(s), {v} \
1685 whole-owned volume(s), {i} identity(ies), {sl} sightline(s), {w} view(s).",
1686 pn = self.pinned,
1687 dv = self.derived,
1688 cc = self.components,
1689 b = self.boxes,
1690 p = self.box_pairs,
1691 s = self.seams,
1692 st = self.stair_seams,
1693 sd = self.drop_seams,
1694 d = self.datums,
1695 v = self.volumes,
1696 i = self.identities,
1697 sl = self.sightlines,
1698 w = self.views,
1699 )
1700 }
1701}
1702
1703// ---------------------------------------------------------------------------
1704// Validation (spec-0049 §4.3) — every rule, all upstream of any geometry
1705// ---------------------------------------------------------------------------
1706
1707/// Every check the site plan owes, at **validation** tier (exit 1).
1708///
1709/// # What invokes this, and what happens without it
1710///
1711/// [`crate::validate::validate_campaign_with`], whenever the campaign directory
1712/// holds a `site-plan.json` — the same event-bound shape stages 2, 3 and 7 use.
1713/// That function is what **every** `delvec` subcommand's validation stage calls,
1714/// so there is no path from a campaign directory to a verdict, a world or a
1715/// datapack that goes round it: `validate`, `analyze` and `build` all enter
1716/// through it, and a caller who somehow skipped it would have no `Campaign` to
1717/// hand anything downstream. There is no flag to pass, no step in a document to
1718/// remember, and no second entry point.
1719///
1720/// # Why every rule is here and none is at analysis tier
1721///
1722/// Round 2 put the layout graph's *reachability* proofs at analysis tier because
1723/// reachability is a question about a whole graph, the tier its quest-graph
1724/// siblings answer at. Nothing here is that question. Every rule below is a
1725/// property of the document in front of it — does this box fit, do these two
1726/// boxes touch, does this number match the brief's — so all of it is validation,
1727/// and a plan that is wrong is wrong before anything is analyzed.
1728///
1729/// # What is deliberately NOT here, and where it went
1730///
1731/// Three obligations of this stage are only decidable once the blockout exists,
1732/// and each is named rather than approximated:
1733///
1734/// * whether a built seam is the opening the plan allocated, whether every node's
1735/// floor is reached, and whether any crossing was *discovered* outside a seam;
1736/// * `DW0833`'s second call site, the identities recomputed from assembled bytes;
1737/// * whether a declared sightline is unobstructed.
1738///
1739/// All three read blocks. Writing a version of them here that read the plan
1740/// instead would be the derivation's arithmetic replayed against itself — the
1741/// opposite of an independent observer — so they belong to the round that builds
1742/// the blockout, and this module states the plan-side half they will be checked
1743/// against.
1744pub fn check(c: &Campaign, reads: &mut Reads, d: &mut Vec<Diagnostic>) {
1745 let Some(plan) = c.site_plan.as_ref().map(|p| &p.content) else {
1746 return;
1747 };
1748 let table = Metrics::table();
1749
1750 // Plan-internal wellformedness first: the ids every rule below quotes.
1751 ids(plan, d);
1752 one_authority(c, d);
1753
1754 // ------------------------------------------------------------- the tooth
1755 // A site plan validates ONLY against a layout graph and a geometry brief.
1756 // Both are refused by name, and the graph's absence returns: every rule
1757 // below reads node ids, and without a graph each would be answering about a
1758 // place nothing declared.
1759 let brief_missing = c.geometry_brief.is_none();
1760 if brief_missing {
1761 d.push(Diagnostic::error(
1762 DW_PLAN_AGREEMENT,
1763 "site-plan",
1764 "",
1765 "this campaign carries a site plan and no `geometry-brief.json`. The plan is the \
1766 embedding of a design, and the brief is where that design's numbers are written \
1767 down; with no brief there is nothing for `identities[]` to hold the map to, and \
1768 the region's extent is a number with no author. Write the brief first — a plan \
1769 cannot reach green ahead of it."
1770 .to_string(),
1771 ));
1772 }
1773 let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
1774 d.push(Diagnostic::error(
1775 DW_PLAN_AGREEMENT,
1776 "site-plan",
1777 "",
1778 "this campaign carries a site plan and no `layout-graph.json`. A site plan is the \
1779 geometric embedding OF a layout graph: every box names a place and every seam \
1780 names a connection, so with no graph there is nothing being embedded and every \
1781 name in this document resolves to nothing. This is the only line this state \
1782 raises: the map's anchor vocabulary is derived from the graph too, so every \
1783 anchor a `npcs`, `quests` or effect document names is left unjudged here rather \
1784 than refused against an empty set, and is judged the moment the graph exists. \
1785 Author the graph first — that ordering \
1786 is what this refusal exists to make uncompilable rather than merely advised."
1787 .to_string(),
1788 ));
1789 return;
1790 };
1791
1792 openers(c, graph, d);
1793 let (placed, packed) = resolve(plan, graph, &table, reads, d);
1794 agreement(plan, graph, &placed, d);
1795 grid(&placed, &table, reads, d);
1796 region(plan, &placed, d);
1797 disjoint(&placed, d);
1798 seams(plan, graph, &placed, &packed, &table, reads, d);
1799 size_classes(&placed, d);
1800 volumes_outside_boxes(plan, &placed, d);
1801 identities(c, plan, &placed, d);
1802 lighting(plan, d);
1803}
1804
1805/// `DW0839`: a campaign has ONE placement authority.
1806///
1807/// `areas[]` places pieces on the fixed stride; the site plan places the whole
1808/// map in its own region. A world carrying both has two owners for one question
1809/// and no rule to pick between them, so the answer is not to arbitrate but to
1810/// refuse. Both surfaces stay legal at 0.14.0 — one per campaign.
1811fn one_authority(c: &Campaign, d: &mut Vec<Diagnostic>) {
1812 let n = c.world.content.areas.len();
1813 if n == 0 {
1814 return;
1815 }
1816 d.push(Diagnostic::error(
1817 DW_TWO_AUTHORITIES,
1818 "world",
1819 "/content/areas",
1820 format!(
1821 "this campaign declares {n} `areas[]` entry(ies) AND a site plan. Those are two \
1822 placement authorities for one world: `areas[]` seats prefab pieces on the compiler's \
1823 fixed stride, and the site plan seats the derived blockout inside its own declared \
1824 `region` — so every question about where something is has two answers and nothing \
1825 says which. Keep one. A campaign that places pieces keeps `areas[]` and drops \
1826 `site-plan.json`; a campaign whose map is the site plan declares an empty `areas` \
1827 list and lets the plan own the space. Both surfaces are legal — what is not legal \
1828 is one \
1829 campaign holding both."
1830 ),
1831 ));
1832}
1833
1834/// `DW0818`'s **byte-side half** of the opener obligation, which round 3 could
1835/// not write and named as this round's.
1836///
1837/// The graph half already stands in [`crate::layout`]: a `barred` edge must
1838/// declare a `gating` that names a flag some effect really sets or a quest that
1839/// really exists. That says the way is *meant* to open; it does not say anything
1840/// in the campaign ever opens **this** way, because at stage 3 the region such
1841/// an effect would target does not exist yet. It exists here: the derivation
1842/// synthesizes [`seam_anchor`] over every `barred` seam's opening, so "something
1843/// opens `seam/<edge>`" is finally a question with a subject.
1844///
1845/// Raised under `DW0818` and against the layout graph, because the fault is the
1846/// graph's claim rather than the plan's geometry — the plan did everything asked
1847/// of it. Only reachable in a site-plan campaign, which is exactly the campaign
1848/// in which the seam anchor exists to be named.
1849fn openers(c: &Campaign, graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
1850 // Every gate region the campaign opens, however it opens it: an `open-gate`
1851 // at any nesting depth, or a `shortcut` whose far side lifts the bar.
1852 let mut opened: BTreeSet<&str> = BTreeSet::new();
1853 crate::stages::for_each_campaign_effect(c, &mut |_, _, eff| {
1854 eff.visit_deep(&mut |e| {
1855 if let Some(a) = e.open_gate_anchor() {
1856 opened.insert(a.0.as_str());
1857 }
1858 });
1859 });
1860 for s in &c.quests.content.shortcuts {
1861 opened.insert(s.gate.0.as_str());
1862 }
1863
1864 for (i, e) in graph.edges.iter().enumerate() {
1865 let Edge::Barred { id, .. } = e else { continue };
1866 let want = seam_anchor(id);
1867 if opened.contains(want.as_str()) {
1868 continue;
1869 }
1870 d.push(Diagnostic::error(
1871 crate::layout::DW_GRAPH_MISSION,
1872 "layout-graph",
1873 format!("/content/edges/{i}"),
1874 format!(
1875 "`{id}` is barred and nothing in this campaign opens it. The derivation seals \
1876 this seam's opening at world load and names the region `{want}`; for the way to \
1877 ever be passable some effect has to address that name — an `open-gate` on the \
1878 beat whose completion earns it, or a `shortcut` whose far side lifts the bar. \
1879 The graph's `gating` says what a body must HOLD to pass, which is a different \
1880 claim and is already checked: a way that is gated on a flag nobody spends is \
1881 still a wall. This is the half of the obligation that could only be written once \
1882 the region existed, so it is asked here rather than at stage 3."
1883 ),
1884 ));
1885 }
1886}
1887
1888/// `datum/`, `volume/` and `view/` ids: well formed and unique, like every other
1889/// id in the DSL. An id is an id, so these are the ordinary `DW0110`/`DW0111`.
1890fn ids(plan: &SitePlanContent, d: &mut Vec<Diagnostic>) {
1891 let mut seen: BTreeSet<String> = BTreeSet::new();
1892 let mut check_id = |ok: bool, id: String, kind: &str, path: String, d: &mut Vec<Diagnostic>| {
1893 if !ok {
1894 d.push(Diagnostic::error(
1895 crate::codes::ID_SYNTAX,
1896 "site-plan",
1897 path.clone(),
1898 format!("malformed {kind} id `{id}` — expected `{kind}/<kebab-case>`."),
1899 ));
1900 }
1901 if !seen.insert(format!("{kind}:{id}")) {
1902 d.push(Diagnostic::error(
1903 crate::codes::ID_DUPLICATE,
1904 "site-plan",
1905 path,
1906 format!(
1907 "duplicate {kind} id `{id}` — rename one, because anything naming it would \
1908 otherwise name both."
1909 ),
1910 ));
1911 }
1912 };
1913 for (i, dat) in plan.datums.iter().enumerate() {
1914 check_id(
1915 dat.id.is_valid_syntax(),
1916 dat.id.0.clone(),
1917 "datum",
1918 format!("/content/datums/{i}/id"),
1919 d,
1920 );
1921 }
1922 for (i, v) in plan.volumes.iter().enumerate() {
1923 check_id(
1924 v.id.is_valid_syntax(),
1925 v.id.0.clone(),
1926 "volume",
1927 format!("/content/volumes/{i}/id"),
1928 d,
1929 );
1930 }
1931 for (i, v) in plan.views.iter().enumerate() {
1932 check_id(
1933 v.id.is_valid_syntax(),
1934 v.id.0.clone(),
1935 "view",
1936 format!("/content/views/{i}/id"),
1937 d,
1938 );
1939 }
1940}
1941
1942// ---------------------------------------------------------------------------
1943// Packing — a box is placed by its seam, and the grid is derived (spec-0059 §3)
1944// ---------------------------------------------------------------------------
1945
1946/// How a box came to stand where it stands.
1947#[derive(Debug, Clone, PartialEq, Eq)]
1948pub enum Provenance {
1949 /// The author pinned its `min`.
1950 Pinned,
1951 /// A seam hung it off a box that already stood.
1952 Seam {
1953 /// Index into `seams[]`.
1954 seam: usize,
1955 /// The connection the seam allocates.
1956 edge: EdgeId,
1957 /// The box it was hung off.
1958 from: NodeId,
1959 /// The face of `from` it hangs off — or, when `from` is the seam's `b`
1960 /// end, the face of this box the seam names.
1961 face: Face,
1962 },
1963}
1964
1965impl Provenance {
1966 /// The words a refusal or the placing line uses.
1967 fn describe(&self) -> String {
1968 match self {
1969 Provenance::Pinned => "pinned".to_string(),
1970 Provenance::Seam {
1971 edge, from, face, ..
1972 } => format!(
1973 "hung off `{from}` across the {face} face by the seam for `{edge}`",
1974 face = face.as_str()
1975 ),
1976 }
1977 }
1978}
1979
1980/// One box's corner, with its provenance.
1981#[derive(Debug, Clone, PartialEq, Eq)]
1982pub struct PackedBox {
1983 /// The place.
1984 pub node: NodeId,
1985 /// Low corner `[x, z]`, in world coordinates.
1986 pub min: [i64; 2],
1987 /// Where the corner came from.
1988 pub by: Provenance,
1989}
1990
1991/// The packing of one plan: every corner the pins and seams settle, and the
1992/// world anchor of every seam whose two ends stand.
1993#[derive(Debug, Default)]
1994struct Packed {
1995 /// Per `plan.boxes` index; `None` when nothing placed the box.
1996 boxes: Vec<Option<PackedBox>>,
1997 /// Per `plan.seams` index: the crossing's low corner on the face's own two
1998 /// world axes — `[along, sill]` on a wall, `[x, z]` through a floor or
1999 /// ceiling. `None` when either end is unplaced or a floor is unresolved.
2000 seam_at: Vec<Option<[i64; 2]>>,
2001 /// Seams the packing itself refused; [`seams`] does not judge them twice.
2002 refused: BTreeSet<usize>,
2003 /// Connected components of the seam graph, over boxes the graph declares.
2004 components: usize,
2005 pinned: usize,
2006 derived: usize,
2007}
2008
2009/// A seam resolved far enough to place a box: its two box indices and its two
2010/// offsets, each `[u, v]` on a horizontal face and `[u, 0]` on a vertical one.
2011#[derive(Debug, Clone, Copy)]
2012struct Link {
2013 a: usize,
2014 b: usize,
2015 at: [i64; 2],
2016 meets: [i64; 2],
2017}
2018
2019/// The two horizontal-axis indices a face's arithmetic uses: `(normal, along)`
2020/// into `[x, z]`.
2021fn face_axes(face: Face) -> (usize, usize) {
2022 match face {
2023 Face::East | Face::West => (0, 1),
2024 Face::North | Face::South => (1, 0),
2025 Face::Up | Face::Down => (0, 1), // unused: a horizontal face has no normal in [x, z]
2026 }
2027}
2028
2029/// The width a crossing is centred by, on the face's own two axes, or `None`
2030/// for a contact with no extent — the whole face, so the offsets default to 0.
2031/// `Err` when the opening cannot be resolved (`DW0812`/`DW0876` name that).
2032fn centring_width(s: &Seam, table: &Metrics, reads: &mut Reads) -> Result<Option<[i64; 2]>, ()> {
2033 if let Some(c) = &s.contact {
2034 return Ok(c
2035 .extent
2036 .map(|e| [i64::from(e[0].get()), i64::from(e[1].get())]));
2037 }
2038 let Some(name) = s.opening.as_deref() else {
2039 return Err(());
2040 };
2041 let entry = table.resolve(MetricKind::Opening, name).map_err(|_| ())?;
2042 match entry.value(reads) {
2043 MetricValue::Opening(o) => Ok(Some([i64::from(o.width), i64::from(o.height)])),
2044 _ => Err(()),
2045 }
2046}
2047
2048/// Why an offset is not a position on its face.
2049enum OffsetProblem {
2050 /// The shape does not match the face: an integer through a floor, a pair
2051 /// on a wall.
2052 Shape { which: &'static str },
2053 /// The crossing, anchored there, leaves the box's own face.
2054 OffFace {
2055 which: &'static str,
2056 axis: &'static str,
2057 value: i64,
2058 extent: i64,
2059 width: i64,
2060 },
2061}
2062
2063/// Resolve `at` and `meets` for one seam against its two boxes' extents:
2064/// declared values taken as written, omitted ones centred (spec-0059 §3).
2065fn offsets(
2066 s: &Seam,
2067 ext_a: [i64; 2],
2068 ext_b: [i64; 2],
2069 width: Option<[i64; 2]>,
2070) -> Result<([i64; 2], [i64; 2]), OffsetProblem> {
2071 let horizontal = s.face.is_horizontal_plane();
2072 let one = |which: &'static str,
2073 declared: Option<Offset>,
2074 ext: [i64; 2]|
2075 -> Result<[i64; 2], OffsetProblem> {
2076 // Which of `[dx, dz]` each offset component runs along, and its name.
2077 let (axes, names): ([usize; 2], [&'static str; 2]) = if horizontal {
2078 ([0, 1], ["x", "z"])
2079 } else {
2080 let (_, along) = face_axes(s.face);
2081 ([along, 0], [if along == 0 { "x" } else { "z" }, ""])
2082 };
2083 // Centred; a crossing wider than the face centres at the corner and
2084 // whether it fits is `DW0829`'s or `DW0876`'s question.
2085 let default = |i: usize| match width {
2086 Some(w) => (ext[axes[i]] - w[i]).div_euclid(2).max(0),
2087 None => 0,
2088 };
2089 let off = match (declared, horizontal) {
2090 (None, true) => [default(0), default(1)],
2091 (None, false) => [default(0), 0],
2092 (Some(Offset::Plane(p)), true) => p,
2093 (Some(Offset::Along(u)), false) => [u, 0],
2094 _ => return Err(OffsetProblem::Shape { which }),
2095 };
2096 let n = if horizontal { 2 } else { 1 };
2097 for i in 0..n {
2098 // The CORNER is on the face; whether the crossing fits from there is
2099 // `DW0829`'s (a portal) or `DW0876`'s (a contact) question, as ever.
2100 let extent = ext[axes[i]];
2101 let w = width.map_or(1, |w| w[i]);
2102 if off[i] < 0 || off[i] > extent - 1 {
2103 return Err(OffsetProblem::OffFace {
2104 which,
2105 axis: names[i],
2106 value: off[i],
2107 extent,
2108 width: w,
2109 });
2110 }
2111 }
2112 Ok(off)
2113 };
2114 Ok((one("at", s.at, ext_a)?, one("meets", s.meets, ext_b)?))
2115}
2116
2117/// The corner of the box a seam places, from the corner of the one that
2118/// already stands. `from_a` places `b` off `a`; otherwise `a` off `b`.
2119fn derive_corner(
2120 face: Face,
2121 known: [i64; 2],
2122 ext_a: [i64; 2],
2123 ext_b: [i64; 2],
2124 at: [i64; 2],
2125 meets: [i64; 2],
2126 from_a: bool,
2127) -> [i64; 2] {
2128 if face.is_horizontal_plane() {
2129 return if from_a {
2130 [known[0] + at[0] - meets[0], known[1] + at[1] - meets[1]]
2131 } else {
2132 [known[0] - at[0] + meets[0], known[1] - at[1] + meets[1]]
2133 };
2134 }
2135 let (normal, along) = face_axes(face);
2136 let positive = matches!(face, Face::East | Face::South);
2137 let mut out = [0i64; 2];
2138 if from_a {
2139 out[along] = known[along] + at[0] - meets[0];
2140 out[normal] = if positive {
2141 known[normal] + ext_a[normal] + 1
2142 } else {
2143 known[normal] - ext_b[normal] - 1
2144 };
2145 } else {
2146 out[along] = known[along] - at[0] + meets[0];
2147 out[normal] = if positive {
2148 known[normal] - ext_a[normal] - 1
2149 } else {
2150 known[normal] + ext_b[normal] + 1
2151 };
2152 }
2153 out
2154}
2155
2156/// The crossing's low corner in the face's own two world axes — what every
2157/// seam rule judges. `[along, sill]` on a wall, the sill being the higher of
2158/// the two floors; `[x, z]` through a floor or ceiling.
2159fn crossing_anchor(
2160 face: Face,
2161 a_min: [i64; 2],
2162 at: [i64; 2],
2163 floor_a: i64,
2164 floor_b: i64,
2165) -> [i64; 2] {
2166 if face.is_horizontal_plane() {
2167 [a_min[0] + at[0], a_min[1] + at[1]]
2168 } else {
2169 let (_, along) = face_axes(face);
2170 [a_min[along] + at[0], floor_a.max(floor_b)]
2171 }
2172}
2173
2174fn ext_of(b: &PlanBox) -> [i64; 2] {
2175 [i64::from(b.extent[0].get()), i64::from(b.extent[1].get())]
2176}
2177
2178/// **The packing** (spec-0059 §3): the pinned boxes seed it; then `seams[]` in
2179/// document order, repeatedly, each seam with exactly one end standing placing
2180/// the other, until a pass places nothing. A seam whose two ends both stand is
2181/// then checked — the corner it would derive against the corner the box has —
2182/// and a component of the seam graph with no pinned box is refused, because
2183/// nothing places it.
2184fn pack(
2185 plan: &SitePlanContent,
2186 graph: &LayoutGraphContent,
2187 table: &Metrics,
2188 floors: &[Option<i64>],
2189 reads: &mut Reads,
2190 d: &mut Vec<Diagnostic>,
2191) -> Packed {
2192 let mut out = Packed {
2193 boxes: vec![None; plan.boxes.len()],
2194 seam_at: vec![None; plan.seams.len()],
2195 ..Packed::default()
2196 };
2197 let nodes: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
2198 let mut by_node: BTreeMap<&str, usize> = BTreeMap::new();
2199 for (i, b) in plan.boxes.iter().enumerate() {
2200 if nodes.contains(b.node.0.as_str()) {
2201 by_node.entry(b.node.0.as_str()).or_insert(i);
2202 }
2203 }
2204 let edges: BTreeMap<&str, &Edge> = graph.edges.iter().map(|e| (e.id().0.as_str(), e)).collect();
2205
2206 // ---- seeds
2207 for (i, b) in plan.boxes.iter().enumerate() {
2208 if let Some(min) = b.min {
2209 out.boxes[i] = Some(PackedBox {
2210 node: b.node.clone(),
2211 min,
2212 by: Provenance::Pinned,
2213 });
2214 out.pinned += 1;
2215 }
2216 }
2217
2218 // ---- the links: every seam whose edge and boxes resolve, with its offsets
2219 let mut pairs: Vec<(usize, usize)> = Vec::new();
2220 let mut links: Vec<Option<Link>> = Vec::with_capacity(plan.seams.len());
2221 for (i, s) in plan.seams.iter().enumerate() {
2222 let Some(edge) = edges.get(s.edge.0.as_str()) else {
2223 links.push(None);
2224 continue; // `DW0824` refused the reference.
2225 };
2226 if !edge.has_seam() {
2227 links.push(None);
2228 continue; // `DW0824` said this carries a sightline.
2229 }
2230 let (Some(&a), Some(&b)) = (
2231 by_node.get(edge.a().0.as_str()),
2232 by_node.get(edge.b().0.as_str()),
2233 ) else {
2234 links.push(None);
2235 continue; // `DW0824` reported the missing box.
2236 };
2237 pairs.push((a, b));
2238 // An unresolvable opening (`DW0812`/`DW0876` say so) centres nothing:
2239 // the crossing is taken one cell wide at the corner, so the seam still
2240 // places and the rules that name the opening run over a plan that
2241 // stands.
2242 let width = centring_width(s, table, reads).unwrap_or_default();
2243 match offsets(s, ext_of(&plan.boxes[a]), ext_of(&plan.boxes[b]), width) {
2244 Ok((at, meets)) => links.push(Some(Link { a, b, at, meets })),
2245 Err(problem) => {
2246 d.push(offset_problem(i, s, edge, problem));
2247 out.refused.insert(i);
2248 links.push(None);
2249 }
2250 }
2251 }
2252
2253 // ---- placement passes
2254 loop {
2255 let mut changed = false;
2256 for (i, link) in links.iter().enumerate() {
2257 let Some(l) = link else { continue };
2258 let s = &plan.seams[i];
2259 let (ext_a, ext_b) = (ext_of(&plan.boxes[l.a]), ext_of(&plan.boxes[l.b]));
2260 match (out.boxes[l.a].clone(), out.boxes[l.b].clone()) {
2261 (Some(a), None) => {
2262 let min = derive_corner(s.face, a.min, ext_a, ext_b, l.at, l.meets, true);
2263 out.boxes[l.b] = Some(PackedBox {
2264 node: plan.boxes[l.b].node.clone(),
2265 min,
2266 by: Provenance::Seam {
2267 seam: i,
2268 edge: s.edge.clone(),
2269 from: a.node,
2270 face: s.face,
2271 },
2272 });
2273 out.derived += 1;
2274 changed = true;
2275 }
2276 (None, Some(b)) => {
2277 let min = derive_corner(s.face, b.min, ext_a, ext_b, l.at, l.meets, false);
2278 out.boxes[l.a] = Some(PackedBox {
2279 node: plan.boxes[l.a].node.clone(),
2280 min,
2281 by: Provenance::Seam {
2282 seam: i,
2283 edge: s.edge.clone(),
2284 from: b.node,
2285 face: s.face,
2286 },
2287 });
2288 out.derived += 1;
2289 changed = true;
2290 }
2291 _ => {}
2292 }
2293 }
2294 if !changed {
2295 break;
2296 }
2297 }
2298
2299 // ---- every seam whose two ends stand: one placement, and the anchor
2300 for (i, link) in links.iter().enumerate() {
2301 let Some(l) = link else { continue };
2302 let (Some(a), Some(b)) = (&out.boxes[l.a], &out.boxes[l.b]) else {
2303 continue;
2304 };
2305 let s = &plan.seams[i];
2306 let (ext_a, ext_b) = (ext_of(&plan.boxes[l.a]), ext_of(&plan.boxes[l.b]));
2307 let want = derive_corner(s.face, a.min, ext_a, ext_b, l.at, l.meets, true);
2308 if want != b.min {
2309 d.push(two_placements(i, s, a, b, want));
2310 out.refused.insert(i);
2311 continue;
2312 }
2313 if let (Some(fa), Some(fb)) = (floors[l.a], floors[l.b]) {
2314 out.seam_at[i] = Some(crossing_anchor(s.face, a.min, l.at, fa, fb));
2315 }
2316 }
2317
2318 // ---- components with nothing to place them
2319 let mut parent: Vec<usize> = (0..plan.boxes.len()).collect();
2320 fn find(p: &mut [usize], i: usize) -> usize {
2321 let mut r = i;
2322 while p[r] != r {
2323 r = p[r];
2324 }
2325 let mut c = i;
2326 while p[c] != r {
2327 let n = p[c];
2328 p[c] = r;
2329 c = n;
2330 }
2331 r
2332 }
2333 for &(a, b) in &pairs {
2334 let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
2335 if ra != rb {
2336 parent[ra.max(rb)] = ra.min(rb);
2337 }
2338 }
2339 let mut members: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
2340 for (i, b) in plan.boxes.iter().enumerate() {
2341 if nodes.contains(b.node.0.as_str()) {
2342 let r = find(&mut parent, i);
2343 members.entry(r).or_default().push(i);
2344 }
2345 }
2346 out.components = members.len();
2347 let entry = graph.entry.0.as_str();
2348 for (_, boxes) in members {
2349 if boxes.iter().any(|&i| plan.boxes[i].min.is_some()) {
2350 continue;
2351 }
2352 let names: Vec<String> = boxes
2353 .iter()
2354 .map(|&i| format!("`{}`", plan.boxes[i].node))
2355 .collect();
2356 let suggested = boxes
2357 .iter()
2358 .find(|&&i| plan.boxes[i].node.0 == entry)
2359 .or(boxes.first())
2360 .map(|&i| plan.boxes[i].node.to_string())
2361 .unwrap_or_default();
2362 d.push(Diagnostic::error(
2363 DW_UNPLACED,
2364 "site-plan",
2365 format!("/content/boxes/{}", boxes[0]),
2366 format!(
2367 "nothing places {list}: no box among them pins its `min`, and a box stands \
2368 only where a pin puts it or where a seam hangs it off a box that already \
2369 stands. Pin one of them — `{suggested}` — with `\"min\": [x, z]`, and the \
2370 seams place the rest. {count} box(es) in this component.",
2371 list = names.join(", "),
2372 count = boxes.len(),
2373 ),
2374 ));
2375 }
2376 out
2377}
2378
2379/// `DW0828`: an offset that is not a position on its own box's face.
2380fn offset_problem(i: usize, s: &Seam, edge: &Edge, problem: OffsetProblem) -> Diagnostic {
2381 let (which, detail) = match problem {
2382 OffsetProblem::Shape { which } => (
2383 which,
2384 if s.face.is_horizontal_plane() {
2385 format!(
2386 "the {face} face is a floor or ceiling with two in-plane axes, and `{which}` \
2387 gives one number. Write `[dx, dz]` — cells along x and z from the box's \
2388 low corner",
2389 face = s.face.as_str()
2390 )
2391 } else {
2392 format!(
2393 "the {face} face is a wall with one horizontal axis, and `{which}` gives \
2394 two numbers. Write one — cells along the face from the box's low corner; \
2395 the sill is not written, it is the higher of the two floors",
2396 face = s.face.as_str()
2397 )
2398 },
2399 ),
2400 OffsetProblem::OffFace {
2401 which,
2402 axis,
2403 value,
2404 extent,
2405 width,
2406 } => (
2407 which,
2408 format!(
2409 "`{which}` puts the crossing's corner at {value} along {axis} on a face that \
2410 runs 0..{last} — the crossing is {width} wide and the box is {extent} on that \
2411 axis. Write an offset on the face, or omit it and the crossing is centred; an \
2412 offset is never quietly clamped to fit",
2413 last = extent - 1,
2414 ),
2415 ),
2416 };
2417 Diagnostic::error(
2418 DW_SEAM_NOT_SHARED,
2419 "site-plan",
2420 format!("/content/seams/{i}/{which}"),
2421 format!(
2422 "the seam for `{id}` between `{an}` and `{bn}` names no position on the {side} \
2423 box's face: {detail}.",
2424 id = s.edge,
2425 an = edge.a(),
2426 bn = edge.b(),
2427 side = if which == "at" { "`a`" } else { "`b`" },
2428 ),
2429 )
2430}
2431
2432/// A seam whose two boxes both stand, and which would put `b` somewhere else:
2433/// `DW0883` when a pin is one of the two authorities, `DW0828` when a loop does
2434/// not close.
2435fn two_placements(i: usize, s: &Seam, a: &PackedBox, b: &PackedBox, want: [i64; 2]) -> Diagnostic {
2436 // Two authorities on `b`: its pin and this seam. When `b` was placed by
2437 // another seam, the disagreement is between seams — a loop that does not
2438 // close — however `a` came to stand where it stands.
2439 let pinned = matches!(b.by, Provenance::Pinned);
2440 let where_ = format!(
2441 "`{an}` stands at [{ax}, {az}] ({a_by}); `{bn}` stands at [{bx}, {bz}] ({b_by}); hung \
2442 off `{an}`'s {face} face by this seam, `{bn}` would stand at [{wx}, {wz}]",
2443 an = a.node,
2444 ax = a.min[0],
2445 az = a.min[1],
2446 a_by = a.by.describe(),
2447 bn = b.node,
2448 bx = b.min[0],
2449 bz = b.min[1],
2450 b_by = b.by.describe(),
2451 face = s.face.as_str(),
2452 wx = want[0],
2453 wz = want[1],
2454 );
2455 if pinned {
2456 Diagnostic::error(
2457 DW_UNPLACED,
2458 "site-plan",
2459 format!("/content/seams/{i}"),
2460 format!(
2461 "two things place one box, and they disagree: {where_}. A pin is a claim the \
2462 packing verifies, never a second authority — move the pin to the corner the \
2463 seam derives, delete it and let the seam place the box, or change this seam's \
2464 `at`/`meets` so the two agree.",
2465 ),
2466 )
2467 } else {
2468 Diagnostic::error(
2469 DW_SEAM_NOT_SHARED,
2470 "site-plan",
2471 format!("/content/seams/{i}"),
2472 format!(
2473 "the seam for `{id}` closes a loop, and the loop does not close: {where_}. Every \
2474 box in the loop was placed by an earlier seam, so this one can only check; \
2475 change its `at`/`meets` to where the two boxes really meet, or move the \
2476 offsets of the seams that placed them.",
2477 id = s.edge,
2478 ),
2479 )
2480 }
2481}
2482
2483/// Every box's corner and how it was obtained, one line each — the derivation
2484/// handed back, so a creator reads a corner from the build rather than typing
2485/// it into the document.
2486#[must_use]
2487pub fn placements(c: &Campaign) -> Vec<String> {
2488 let (Some(plan), Some(graph)) = (
2489 c.site_plan.as_ref().map(|p| &p.content),
2490 c.layout_graph.as_ref().map(|g| &g.content),
2491 ) else {
2492 return Vec::new();
2493 };
2494 let table = Metrics::table();
2495 let mut reads = Reads::new();
2496 let mut sink = Vec::new();
2497 let (_, packed) = resolve(plan, graph, &table, &mut reads, &mut sink);
2498 packed
2499 .boxes
2500 .iter()
2501 .flatten()
2502 .map(|b| {
2503 format!(
2504 "site-plan placing: `{node}` stands at [{x}, {z}] — {by}.",
2505 node = b.node,
2506 x = b.min[0],
2507 z = b.min[1],
2508 by = b.by.describe(),
2509 )
2510 })
2511 .collect()
2512}
2513
2514/// Resolve every box once: its footprint, its walk plane, its headroom and its
2515/// size class. A floor naming a datum the plan does not declare is the ordinary
2516/// dangling reference (`DW0112`) and the box is dropped, because a place with no
2517/// plane has no geometry for any rule below to judge.
2518/// The corners come from the packing (spec-0059 §3), which runs here so that
2519/// every reader of the resolved plan — the checks, the derivation, the battery
2520/// — holds one grid.
2521fn resolve<'a>(
2522 plan: &'a SitePlanContent,
2523 graph: &LayoutGraphContent,
2524 table: &Metrics,
2525 reads: &mut Reads,
2526 d: &mut Vec<Diagnostic>,
2527) -> (Vec<Placed<'a>>, Packed) {
2528 let datums: BTreeMap<&str, i64> = plan.datums.iter().map(|x| (x.id.0.as_str(), x.y)).collect();
2529 // Whichever of the two classifications the node declared. `DW0875` is what
2530 // refuses a node that declared both or neither; this map takes the size
2531 // class first so that a node which slipped past with both is judged against
2532 // one of the two rather than against neither — a refused campaign builds
2533 // nothing either way, and a check that quietly examines zero boxes is the
2534 // shape worth avoiding.
2535 let classes: BTreeMap<&str, (MetricKind, &str)> = graph
2536 .nodes
2537 .iter()
2538 .filter_map(|n| {
2539 let named = n
2540 .size_class
2541 .as_deref()
2542 .map(|x| (MetricKind::SizeClass, x))
2543 .or_else(|| n.way_class.as_deref().map(|x| (MetricKind::WayClass, x)))?;
2544 Some((n.id.0.as_str(), named))
2545 })
2546 .collect();
2547 // Floors first: the packing needs them for every sill, and a box with no
2548 // plane has no cells for any reader to work in.
2549 let mut floors: Vec<Option<i64>> = Vec::with_capacity(plan.boxes.len());
2550 for (i, b) in plan.boxes.iter().enumerate() {
2551 floors.push(match &b.floor {
2552 Floor::Y(y) => Some(*y),
2553 Floor::Datum(id) => match datums.get(id.0.as_str()) {
2554 Some(y) => Some(*y),
2555 None => {
2556 d.push(Diagnostic::error(
2557 crate::codes::DANGLING_REF,
2558 "site-plan",
2559 format!("/content/boxes/{i}/floor"),
2560 format!(
2561 "box for `{node}` stands on `{id}`, which this plan declares no \
2562 `datums[]` entry for. Declare the plane, or give the box its own \
2563 `y` — a place with no plane has no walk surface, so nothing below \
2564 can say where it is.",
2565 node = b.node,
2566 ),
2567 ));
2568 None
2569 }
2570 },
2571 });
2572 }
2573 let packed = pack(plan, graph, table, &floors, reads, d);
2574 let mut out = Vec::new();
2575 for (i, b) in plan.boxes.iter().enumerate() {
2576 let (Some(floor), Some(pb)) = (floors[i], &packed.boxes[i]) else {
2577 continue; // `DW0112` or `DW0883` said why this box has no cells.
2578 };
2579 let class = classes
2580 .get(b.node.0.as_str())
2581 .and_then(|(kind, name)| table.resolve(*kind, name).ok())
2582 .and_then(|entry| match entry.value(reads) {
2583 MetricValue::SizeClass(sc) => Some(PlaceClass::Size(*sc)),
2584 MetricValue::WayClass(w) => Some(PlaceClass::Way(*w)),
2585 _ => None,
2586 });
2587 let clearance = match b.ceiling {
2588 Ceiling::Clearance(c) => Some(c.get()),
2589 // A sky-open place claims its class's own minimum headroom and
2590 // nothing above it: an open place is precisely one that makes no
2591 // claim on the air over it. True of both kinds of class, which is
2592 // why the question is asked of the classification rather than of
2593 // one of its variants.
2594 Ceiling::Open => class.map(PlaceClass::min_clearance),
2595 };
2596 out.push(Placed {
2597 index: i,
2598 plan: b,
2599 foot: [
2600 pb.min[0],
2601 pb.min[0] + i64::from(b.extent[0].get()) - 1,
2602 pb.min[1],
2603 pb.min[1] + i64::from(b.extent[1].get()) - 1,
2604 ],
2605 floor,
2606 clearance,
2607 class,
2608 by: pb.by.clone(),
2609 });
2610 }
2611 (out, packed)
2612}
2613
2614/// `DW0824`: the graph and the plan agree **exactly**, in both directions.
2615///
2616/// Six correspondences and three references, all one claim: everything the graph
2617/// declares is embedded exactly once, and everything the plan embeds is
2618/// something the graph declared.
2619///
2620/// This check is also the **two-artifact question's instrument** (spec-0049
2621/// §10): how often it fires *alone* — a graph edit with no plan edit or the
2622/// reverse — is the measurable evidence that decides whether the graph and the
2623/// plan stay two documents or merge into one.
2624///
2625/// It reads `plan.boxes` rather than the resolved list, deliberately: a box
2626/// whose floor named no datum is still a box the author wrote, and reporting it
2627/// as a place with no box as well would answer a question nobody asked.
2628fn agreement(
2629 plan: &SitePlanContent,
2630 graph: &LayoutGraphContent,
2631 placed: &[Placed<'_>],
2632 d: &mut Vec<Diagnostic>,
2633) {
2634 let fault = |path: String, msg: String, d: &mut Vec<Diagnostic>| {
2635 d.push(Diagnostic::error(DW_PLAN_AGREEMENT, "site-plan", path, msg));
2636 };
2637
2638 // -------------------------------------------------------------- places
2639 let nodes: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
2640 let mut boxed: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
2641 for (i, b) in plan.boxes.iter().enumerate() {
2642 if !nodes.contains(b.node.0.as_str()) {
2643 fault(
2644 format!("/content/boxes/{i}/node"),
2645 format!(
2646 "this box embeds `{n}`, which the layout graph declares no place for. A box \
2647 is the geometry OF a place; one that names nothing is a room the map has \
2648 no reason to contain. Declare the place, or delete the box.",
2649 n = b.node,
2650 ),
2651 d,
2652 );
2653 continue;
2654 }
2655 boxed.entry(b.node.0.as_str()).or_default().push(i);
2656 }
2657 for (i, n) in graph.nodes.iter().enumerate() {
2658 match boxed.get(n.id.0.as_str()).map_or(0, Vec::len) {
2659 1 => {}
2660 0 => fault(
2661 "/content/boxes".to_string(),
2662 format!(
2663 "place `{id}` has no box. Every place the graph declares is embedded exactly \
2664 once — an unembedded place is a room the plan forgot, and nothing later can \
2665 notice it, because every geometric rule quantifies over the boxes that \
2666 exist. (Graph node {i} of {total}.)",
2667 id = n.id,
2668 total = graph.nodes.len(),
2669 ),
2670 d,
2671 ),
2672 k => fault(
2673 "/content/boxes".to_string(),
2674 format!(
2675 "place `{id}` has {k} boxes. A place is one space; two boxes for it make \
2676 every rule below pick one of them and no rule says which.",
2677 id = n.id,
2678 ),
2679 d,
2680 ),
2681 }
2682 }
2683
2684 // --------------------------------------------------------- connections
2685 let edges: BTreeMap<&str, &Edge> = graph.edges.iter().map(|e| (e.id().0.as_str(), e)).collect();
2686 let mut seamed: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
2687 for (i, s) in plan.seams.iter().enumerate() {
2688 match edges.get(s.edge.0.as_str()) {
2689 None => fault(
2690 format!("/content/seams/{i}/edge"),
2691 format!(
2692 "this seam allocates `{e}`, which the layout graph declares no connection \
2693 for. A seam is an opening cut for a connection; one that names nothing is a \
2694 hole in a wall for no reason.",
2695 e = s.edge,
2696 ),
2697 d,
2698 ),
2699 Some(Edge::Vision { .. }) => fault(
2700 format!("/content/seams/{i}/edge"),
2701 format!(
2702 "`{e}` is a `vision` connection and carries a **sightline**, not a seam. A \
2703 seam is an opening on a shared face, and a vista's two ends are routinely \
2704 not adjacent — a tower seen from a shore shares no face with it — so the \
2705 seam construct cannot state the one thing a vision connection asserts. Move \
2706 it to `sightlines[]`.",
2707 e = s.edge,
2708 ),
2709 d,
2710 ),
2711 Some(Edge::Carry { .. }) => fault(
2712 format!("/content/seams/{i}/edge"),
2713 format!(
2714 "`{e}` is a `carry` connection: a body crosses it by being put down on the \
2715 far side by a link, so no opening is cut for it. Remove this seam.",
2716 e = s.edge,
2717 ),
2718 d,
2719 ),
2720 Some(_) => {
2721 seamed.entry(s.edge.0.as_str()).or_default().push(i);
2722 }
2723 }
2724 }
2725 let mut sighted: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
2726 for (i, s) in plan.sightlines.iter().enumerate() {
2727 match edges.get(s.edge.0.as_str()) {
2728 None => fault(
2729 format!("/content/sightlines/{i}/edge"),
2730 format!(
2731 "this sightline embeds `{e}`, which the layout graph declares no connection \
2732 for.",
2733 e = s.edge,
2734 ),
2735 d,
2736 ),
2737 Some(Edge::Carry { .. }) => fault(
2738 format!("/content/sightlines/{i}/edge"),
2739 format!(
2740 "`{e}` is a `carry` connection: a body crosses it by being put down on the \
2741 far side by a link, so nothing is embedded for it. Remove this sightline.",
2742 e = s.edge,
2743 ),
2744 d,
2745 ),
2746 Some(e) if !matches!(e, Edge::Vision { .. }) => fault(
2747 format!("/content/sightlines/{i}/edge"),
2748 format!(
2749 "`{id}` is a `{class}` connection: a body passes along it, so it is allocated \
2750 a **seam** on a shared face rather than a line of sight. Move it to \
2751 `seams[]`.",
2752 id = s.edge,
2753 class = e.class(),
2754 ),
2755 d,
2756 ),
2757 Some(_) => {
2758 sighted.entry(s.edge.0.as_str()).or_default().push(i);
2759 }
2760 }
2761 }
2762 for (i, e) in graph.edges.iter().enumerate() {
2763 // A carry is owed neither: a body crosses it by being put down on the
2764 // far side, and the geometry has nothing to allocate for that.
2765 if matches!(e, Edge::Carry { .. }) {
2766 continue;
2767 }
2768 let (what, held, other) = if e.is_traversal() {
2769 ("seam", &seamed, "seams")
2770 } else {
2771 ("sightline", &sighted, "sightlines")
2772 };
2773 match held.get(e.id().0.as_str()).map_or(0, Vec::len) {
2774 1 => {}
2775 0 => fault(
2776 format!("/content/{other}"),
2777 format!(
2778 "connection `{id}` ({class}) has no {what}. **Seams are allocated, not \
2779 discovered**: two places connect because the plan cut an opening between \
2780 them while both were still free to move, never because a wall happened to \
2781 be low somewhere. A connection with nothing allocated is a promise the \
2782 geometry has not been asked to keep. (Graph edge {i} of {total}.)",
2783 id = e.id(),
2784 class = e.class(),
2785 total = graph.edges.len(),
2786 ),
2787 d,
2788 ),
2789 k => fault(
2790 format!("/content/{other}"),
2791 format!(
2792 "connection `{id}` has {k} {what}s. One connection is one way through; two \
2793 openings for it are two ways, and the graph declared one.",
2794 id = e.id(),
2795 ),
2796 d,
2797 ),
2798 }
2799 }
2800
2801 // ---------------------------------- what a seam says ABOUT its connection
2802 let by_node: BTreeMap<&str, &Placed<'_>> =
2803 placed.iter().map(|p| (p.plan.node.0.as_str(), p)).collect();
2804 for (i, s) in plan.seams.iter().enumerate() {
2805 let Some(e) = edges.get(s.edge.0.as_str()) else {
2806 continue;
2807 };
2808 let Some(host) = &s.stair_in else {
2809 continue;
2810 };
2811 if !matches!(e, Edge::Stair { .. }) {
2812 fault(
2813 format!("/content/seams/{i}/stair_in"),
2814 format!(
2815 "this seam declares stair massing in `{host}`, and `{id}` is a `{class}` \
2816 connection. Only a stair is built out of treads; on anything else the \
2817 declaration is a fact about the geometry that the graph contradicts.",
2818 id = s.edge,
2819 class = e.class(),
2820 ),
2821 d,
2822 );
2823 } else if host != e.a() && host != e.b() {
2824 fault(
2825 format!("/content/seams/{i}/stair_in"),
2826 format!(
2827 "this seam hosts its stair in `{host}`, which is neither end of `{id}` \
2828 (`{a}` and `{b}`). A stair stands in one of the two places it joins.",
2829 id = s.edge,
2830 a = e.a(),
2831 b = e.b(),
2832 ),
2833 d,
2834 );
2835 }
2836 }
2837
2838 // ------------------------- what a sightline says ABOUT its two places
2839 for (i, s) in plan.sightlines.iter().enumerate() {
2840 let Some(e) = edges.get(s.edge.0.as_str()) else {
2841 continue;
2842 };
2843 for (end, point, node) in [("from", s.from, e.a()), ("to", s.to, e.b())] {
2844 let Some(p) = by_node.get(node.0.as_str()) else {
2845 continue;
2846 };
2847 if contains_point(p, point) {
2848 continue;
2849 }
2850 fault(
2851 format!("/content/sightlines/{i}/{end}"),
2852 format!(
2853 "`{id}` is a line of sight between `{a}` and `{b}`, and its `{end}` end \
2854 `[{x}, {y}, {z}]` is not inside `{node}`. The stage-5 proof walks exactly \
2855 this segment and calls the result the vista's; a segment whose ends are \
2856 somewhere else would be proving a different claim, green or red.",
2857 id = s.edge,
2858 a = e.a(),
2859 b = e.b(),
2860 x = point[0],
2861 y = point[1],
2862 z = point[2],
2863 ),
2864 d,
2865 );
2866 }
2867 }
2868}
2869
2870/// Is a world cell inside a place's play space?
2871fn contains_point(p: &Placed<'_>, at: [i64; 3]) -> bool {
2872 if at[0] < p.x0() || at[0] > p.x1() || at[2] < p.z0() || at[2] > p.z1() {
2873 return false;
2874 }
2875 match p.y_span() {
2876 Some((lo, hi)) => at[1] >= lo && at[1] <= hi,
2877 // A sky-open place whose class did not resolve has no stated headroom;
2878 // `DW0812` already refused the name, and inventing a bound here would be
2879 // a second refusal for one defect.
2880 None => at[1] >= p.floor,
2881 }
2882}
2883
2884/// `DW0825`: every box's footprint is a multiple of the kit grid's quantum.
2885fn grid(placed: &[Placed<'_>], table: &Metrics, reads: &mut Reads, d: &mut Vec<Diagnostic>) {
2886 let Some(grid) = table.grid(reads) else {
2887 return; // `Metrics::self_check` owns a table that defines no grid.
2888 };
2889 let q = grid.quantum;
2890 if q == 0 {
2891 return;
2892 }
2893 for p in placed {
2894 for (axis, name) in [(0usize, "x"), (1usize, "z")] {
2895 let e = p.plan.extent[axis].get();
2896 if !off_grid(e, q) {
2897 continue;
2898 }
2899 d.push(Diagnostic::error(
2900 DW_BOX_OFF_GRID,
2901 "site-plan",
2902 format!("/content/boxes/{}/extent/{axis}", p.index),
2903 format!(
2904 "box for `{node}` is {e} blocks on {name}, and the kit grid's quantum is \
2905 {q} — so it is not a multiple of it. Every box's footprint is a whole \
2906 number of quanta on both horizontal axes, which is what lets a kit piece \
2907 land in one without being cut. The nearest multiples are {lo} and {hi}.",
2908 node = p.plan.node,
2909 lo = e - e % q,
2910 hi = e - e % q + q,
2911 ),
2912 ));
2913 }
2914 }
2915}
2916
2917/// **`DW0825`'s own test, for one footprint on one axis.** One function so that
2918/// a verdict computed from a box can ask the SAME question the refusal asked,
2919/// rather than re-deriving the kit-grid rule beside it.
2920fn off_grid(extent: u32, quantum: u32) -> bool {
2921 quantum != 0 && !extent.is_multiple_of(quantum)
2922}
2923
2924/// **The clause a stage-6 verdict owes when the allocation it measured against
2925/// is one this plan has already refused** — the DEFER shape of
2926/// [`crate::diagnostic`]'s "one cause, one line".
2927///
2928/// A `details[]` row is judged against a FRAME and a SEAM SET, and both are
2929/// computed from the site plan. When the plan's own refusals have already
2930/// touched them, the stage-6 line is a true, separate finding measured against a
2931/// number the map does not really have — and, worse, the primary is in another
2932/// document, so the reader has no way to see the relation. Measured: widening
2933/// one box by one block printed `DW0825` and `DW0828` in the site plan and then
2934/// `DW0843` and `DW0844` in the detail plan, five codes over three documents,
2935/// with nothing saying which was the edit.
2936///
2937/// So the stage-6 verdicts keep their own lines — each still names a real
2938/// mismatch, and suppressing them is how fixing one thing produces a fresh crop
2939/// of refusals nobody was shown — and gain a clause saying what they are
2940/// downstream of. Two things can be already-refused:
2941///
2942/// 1. the place's own box is off the kit grid (`DW0825`), so its frame is not a
2943/// frame this map will keep;
2944/// 2. the plan DECLARES a seam on this place that it did not resolve — a face
2945/// the two boxes do not share (`DW0828`) or an opening that does not fit
2946/// (`DW0829`) — so the allocated seam set this place answers is short of what
2947/// the author wrote.
2948///
2949/// Empty when the plan settled both, which is the ordinary case and costs one
2950/// pass over the plan's seams.
2951#[must_use]
2952pub fn refused_upstream(
2953 c: &Campaign,
2954 node: &NodeId,
2955 resolved: &[PlacedSeam],
2956 reads: &mut Reads,
2957) -> String {
2958 let Some(plan) = c.site_plan.as_ref().map(|p| &p.content) else {
2959 return String::new();
2960 };
2961 let mut parts: Vec<String> = Vec::new();
2962
2963 if let Some(q) = Metrics::table().grid(reads).map(|g| g.quantum)
2964 && let Some(b) = plan.boxes.iter().find(|b| &b.node == node)
2965 {
2966 let axes: Vec<&str> = [(0usize, "x"), (1usize, "z")]
2967 .into_iter()
2968 .filter(|(a, _)| off_grid(b.extent[*a].get(), q))
2969 .map(|(_, name)| name)
2970 .collect();
2971 if !axes.is_empty() {
2972 parts.push(format!(
2973 "this place's box is off the kit grid on {axes} and `DW0825` has already refused \
2974 it, so the frame above is not one this map keeps",
2975 axes = axes.join(" and "),
2976 ));
2977 }
2978 }
2979
2980 // A seam the plan wrote and did not resolve: the graph edge names this
2981 // place, the plan has a seam row for it, and nothing came out the other end.
2982 let graph_edges: BTreeMap<&str, &Edge> = c
2983 .layout_graph
2984 .as_ref()
2985 .map(|g| {
2986 g.content
2987 .edges
2988 .iter()
2989 .map(|e| (e.id().0.as_str(), e))
2990 .collect()
2991 })
2992 .unwrap_or_default();
2993 let unresolved: Vec<String> = plan
2994 .seams
2995 .iter()
2996 .filter(|s| {
2997 graph_edges
2998 .get(s.edge.0.as_str())
2999 .is_some_and(|e| e.a() == node || e.b() == node)
3000 && !resolved.iter().any(|r| r.edge == s.edge)
3001 })
3002 .map(|s| format!("`{}`", s.edge))
3003 .collect();
3004 if !unresolved.is_empty() {
3005 parts.push(format!(
3006 "the plan writes {n} seam(s) on this place that it does not resolve ({list}), which \
3007 `DW0828` or `DW0829` has already refused, so the allocation this piece is answering \
3008 is short of what the plan says",
3009 n = unresolved.len(),
3010 list = unresolved.join(", "),
3011 ));
3012 }
3013
3014 if parts.is_empty() {
3015 return String::new();
3016 }
3017 format!(
3018 " This measurement stands downstream of a site-plan refusal: {parts}. Repair the plan \
3019 first — this line moves with it.",
3020 parts = parts.join("; "),
3021 )
3022}
3023
3024/// `DW0826`: nothing the plan places leaves the region.
3025///
3026/// **The region is the brief's number flowing down.** A box is never grounds to
3027/// grow it: the prescription in the message is to move or shrink the box, or to
3028/// change the brief's fact and re-derive *visibly* — never to let the extent be
3029/// whatever the parts added up to, which is the failure this whole stage was
3030/// bought to end.
3031///
3032/// Whole-owned volumes answer to it too. A `massif` outside the region is the
3033/// whole owning mass beyond its own declared extent, which is the same
3034/// extent-flows-up defect arriving through the back door.
3035///
3036/// **One region number is one finding, however many boxes stand outside it**
3037/// (`crate::diagnostic`'s "one cause, one line"). The region is a single fact
3038/// handed down by the brief, so a region a course too short puts *every* box
3039/// over the same edge and each per-box refusal prints the same two numbers with
3040/// a different name in front: measured on a 24-place campaign, shortening the
3041/// region by five courses printed 24 identical paragraphs. When more than one
3042/// thing leaves it, this states the count, names every offender with its own
3043/// overrun, and prescribes once. A single offender still gets its own line
3044/// exactly as before — the fold is reachable only where the copies would have
3045/// been.
3046fn region(plan: &SitePlanContent, placed: &[Placed<'_>], d: &mut Vec<Diagnostic>) {
3047 let r = &plan.region;
3048 let spans = [region_span(r, 0), region_span(r, 1), region_span(r, 2)];
3049 // How the region reads once, for the folded arms: the per-item clause
3050 // repeats it, and repeating it N times is most of what made N copies
3051 // unreadable.
3052 let region_text = format!(
3053 "x {}..{}, y {}..{}, z {}..{}",
3054 spans[0].0, spans[0].1, spans[1].0, spans[1].1, spans[2].0, spans[2].1
3055 );
3056
3057 // ---- boxes ----
3058 let mut boxes_out: Vec<Overrun<'_>> = Vec::new();
3059 for p in placed {
3060 let mut bad: Vec<(&'static str, i64, i64)> = Vec::new();
3061 if !within((p.x0(), p.x1()), spans[0]) {
3062 bad.push(("x", p.x0(), p.x1()));
3063 }
3064 if let Some(y) = p.y_span()
3065 && !within(y, spans[1])
3066 {
3067 bad.push(("y", y.0, y.1));
3068 }
3069 if !within((p.z0(), p.z1()), spans[2]) {
3070 bad.push(("z", p.z0(), p.z1()));
3071 }
3072 if !bad.is_empty() {
3073 boxes_out.push(Overrun {
3074 index: p.index,
3075 name: p.plan.node.0.as_str(),
3076 axes: bad,
3077 by: p.by.describe(),
3078 });
3079 }
3080 }
3081 if boxes_out.len() == 1 {
3082 let o = &boxes_out[0];
3083 d.push(Diagnostic::error(
3084 DW_BOX_LEAVES_REGION,
3085 "site-plan",
3086 format!("/content/boxes/{}", o.index),
3087 format!(
3088 "box for `{node}` leaves the region: {bad}. It stands where it stands because it \
3089 is {by}. The region is the whole map's extent, and it comes from the brief — a \
3090 box is never grounds to grow it. Move the box (its pin, or the offsets of the \
3091 seam that placed it), shrink it, or change the brief's fact and re-derive the \
3092 region so the change is visible in the document that owns it.",
3093 by = o.by,
3094 node = o.name,
3095 bad = against_region(&o.axes, &spans),
3096 ),
3097 ));
3098 } else if boxes_out.len() > 1 {
3099 d.push(Diagnostic::error(
3100 DW_BOX_LEAVES_REGION,
3101 "site-plan",
3102 "/content/boxes",
3103 format!(
3104 "{n} of the {total} box(es) this plan places leave the region, which is \
3105 {region_text}: {list}. The region is the whole map's extent, and it comes from \
3106 the brief — a box is never grounds to grow it. One region is the cause of all \
3107 {n} of these, which is why they are one line and not {n}: move or shrink the \
3108 boxes, or change the brief's fact and re-derive the region so the change is \
3109 visible in the document that owns it.",
3110 n = boxes_out.len(),
3111 total = placed.len(),
3112 list = named_overruns(&boxes_out),
3113 ),
3114 ));
3115 }
3116
3117 // ---- whole-owned volumes ----
3118 let mut volumes_out: Vec<Overrun<'_>> = Vec::new();
3119 for (i, v) in plan.volumes.iter().enumerate() {
3120 let vmax = v.region.max();
3121 let mut bad: Vec<(&'static str, i64, i64)> = Vec::new();
3122 for (axis, name) in [(0usize, "x"), (1, "y"), (2, "z")] {
3123 if !within((v.region.min[axis], vmax[axis]), spans[axis]) {
3124 bad.push((name, v.region.min[axis], vmax[axis]));
3125 }
3126 }
3127 if !bad.is_empty() {
3128 volumes_out.push(Overrun {
3129 by: String::new(),
3130 index: i,
3131 name: v.id.0.as_str(),
3132 axes: bad,
3133 });
3134 }
3135 }
3136 if volumes_out.len() == 1 {
3137 let o = &volumes_out[0];
3138 d.push(Diagnostic::error(
3139 DW_BOX_LEAVES_REGION,
3140 "site-plan",
3141 format!("/content/volumes/{}", o.index),
3142 format!(
3143 "whole-owned volume `{id}` leaves the region: {bad}. The region is the whole's \
3144 own extent; mass outside it is the whole growing to fit what was put in it, \
3145 which is the direction this stage exists to forbid.",
3146 id = o.name,
3147 bad = against_region(&o.axes, &spans),
3148 ),
3149 ));
3150 } else if volumes_out.len() > 1 {
3151 d.push(Diagnostic::error(
3152 DW_BOX_LEAVES_REGION,
3153 "site-plan",
3154 "/content/volumes",
3155 format!(
3156 "{n} of the {total} whole-owned volume(s) leave the region, which is \
3157 {region_text}: {list}. The region is the whole's own extent; mass outside it is \
3158 the whole growing to fit what was put in it, which is the direction this stage \
3159 exists to forbid. One region is the cause of all {n} of these, which is why \
3160 they are one line and not {n}.",
3161 n = volumes_out.len(),
3162 total = plan.volumes.len(),
3163 list = named_overruns(&volumes_out),
3164 ),
3165 ));
3166 }
3167}
3168
3169/// One offender's overrun, spelled against the region on each axis it leaves —
3170/// the wording a single finding carries, where the region's own numbers are
3171/// stated beside the box's because there is only one line to read them in.
3172fn against_region(bad: &[(&'static str, i64, i64)], spans: &[(i64, i64); 3]) -> String {
3173 bad.iter()
3174 .map(|(name, lo, hi)| {
3175 let axis = match *name {
3176 "x" => 0,
3177 "y" => 1,
3178 _ => 2,
3179 };
3180 format!(
3181 "{name} {lo}..{hi} against the region's {}..{}",
3182 spans[axis].0, spans[axis].1
3183 )
3184 })
3185 .collect::<Vec<_>>()
3186 .join("; ")
3187}
3188
3189/// One thing the plan places that stands outside the region, and where.
3190///
3191/// A named struct rather than a tuple because both arms read it: the single
3192/// finding addresses its own `index`, the folded one prints `name` and `axes`,
3193/// and boxes and volumes differ in nothing else.
3194struct Overrun<'a> {
3195 /// Position in its own array — the path a single finding is addressed at.
3196 index: usize,
3197 /// The id an author reads.
3198 name: &'a str,
3199 /// Each axis it leaves, with its own inclusive span on that axis.
3200 axes: Vec<(&'static str, i64, i64)>,
3201 /// How its corner was obtained, in the words a refusal uses.
3202 by: String,
3203}
3204
3205/// The offender list a folded finding carries: every name with its own overrun,
3206/// and no repeat of the region — the folded message states that once.
3207fn named_overruns(items: &[Overrun<'_>]) -> String {
3208 items
3209 .iter()
3210 .map(|o| {
3211 format!(
3212 "`{}` ({})",
3213 o.name,
3214 o.axes
3215 .iter()
3216 .map(|(ax, lo, hi)| format!("{ax} {lo}..{hi}"))
3217 .collect::<Vec<_>>()
3218 .join(", ")
3219 )
3220 })
3221 .collect::<Vec<_>>()
3222 .join(", ")
3223}
3224
3225/// `DW0827`: the boxes are disjoint.
3226///
3227/// Shared **faces** are the only permitted contact, because a seam needs one —
3228/// and a shared face is a one-cell gap, not a touch (see [`Placed`]). Two boxes
3229/// whose play spaces meet are two authorities over one cell, which the
3230/// derivation would have to arbitrate and must never be asked to.
3231fn disjoint(placed: &[Placed<'_>], d: &mut Vec<Diagnostic>) {
3232 for (a_i, a) in placed.iter().enumerate() {
3233 for b in &placed[a_i + 1..] {
3234 let (Some(x), Some(z)) = (
3235 overlap((a.x0(), a.x1()), (b.x0(), b.x1())),
3236 overlap((a.z0(), a.z1()), (b.z0(), b.z1())),
3237 ) else {
3238 continue;
3239 };
3240 let y = match (a.y_span(), b.y_span()) {
3241 (Some(ya), Some(yb)) => match overlap(ya, yb) {
3242 Some(y) => y,
3243 None => continue,
3244 },
3245 // One of them is sky-open with an unresolved class; `DW0812`
3246 // owns that name, and the footprints alone are enough to say
3247 // the two places stand in each other.
3248 _ => (a.floor.min(b.floor), a.floor.max(b.floor)),
3249 };
3250 d.push(Diagnostic::error(
3251 DW_BOXES_OVERLAP,
3252 "site-plan",
3253 format!("/content/boxes/{}", b.index),
3254 format!(
3255 "the boxes for `{a_n}` and `{b_n}` overlap, sharing x {x0}..{x1}, y \
3256 {y0}..{y1}, z {z0}..{z1}. Two places may share a FACE — that is what a seam \
3257 is cut through — but never a cell: overlapping boxes are two owners for one \
3258 block, and the derivation would have to pick between them with no rule to \
3259 pick by. Boxes that connect sit one cell apart, and the cell between them \
3260 is the wall they have in common.",
3261 a_n = a.plan.node,
3262 b_n = b.plan.node,
3263 x0 = x.0,
3264 x1 = x.1,
3265 y0 = y.0,
3266 y1 = y.1,
3267 z0 = z.0,
3268 z1 = z.1,
3269 ),
3270 ));
3271 }
3272 }
3273}
3274
3275/// `DW0835`: the whole's mass stands beside, under and over places — never
3276/// inside one.
3277fn volumes_outside_boxes(plan: &SitePlanContent, placed: &[Placed<'_>], d: &mut Vec<Diagnostic>) {
3278 for (i, v) in plan.volumes.iter().enumerate() {
3279 let vmax = v.region.max();
3280 for p in placed {
3281 let (Some(x), Some(z)) = (
3282 overlap((v.region.min[0], vmax[0]), (p.x0(), p.x1())),
3283 overlap((v.region.min[2], vmax[2]), (p.z0(), p.z1())),
3284 ) else {
3285 continue;
3286 };
3287 let Some(py) = p.y_span() else { continue };
3288 let Some(y) = overlap((v.region.min[1], vmax[1]), py) else {
3289 continue;
3290 };
3291 d.push(Diagnostic::error(
3292 DW_VOLUME_IN_BOX,
3293 "site-plan",
3294 format!("/content/volumes/{i}"),
3295 format!(
3296 "whole-owned volume `{id}` ({role}) enters the box for `{node}`, sharing x \
3297 {x0}..{x1}, y {y0}..{y1}, z {z0}..{z1}. The whole's mass may stand beside a \
3298 place, under it and over it; inside it, the volume and the place are two \
3299 authorities writing one cell, and the derivation must never be asked to \
3300 arbitrate that. Pull the volume back to the place's face, or move the \
3301 place.",
3302 id = v.id,
3303 role = v.role.as_str(),
3304 node = p.plan.node,
3305 x0 = x.0,
3306 x1 = x.1,
3307 y0 = y.0,
3308 y1 = y.1,
3309 z0 = z.0,
3310 z1 = z.1,
3311 ),
3312 ));
3313 }
3314 }
3315}
3316
3317/// `DW0832`: a box is built to its place's class — **either kind** (spec-0053
3318/// §3).
3319///
3320/// # The way branch, and why its third demand is structural
3321///
3322/// A size class bounds both horizontal extents and this is the one place that
3323/// becomes geometry. A way class bounds only the **cross-section**, which is the
3324/// box's *shorter* horizontal extent — the axis a body feels — and then demands
3325/// that the **run**, the longer extent, strictly EXCEED the class's
3326/// `max_width`.
3327///
3328/// That third demand is the elongation, and it is deliberately derived from the
3329/// class's own widest cross-section rather than seeded as a constant, because it
3330/// is exactly what a room cannot supply. A square box can never satisfy it: its
3331/// run equals its width, and one number cannot both be `<= max_width` and exceed
3332/// it. So "declare a room a way to escape the size ladder" is refused **by the
3333/// object's own shape** rather than by a rule the author could satisfy by
3334/// choosing differently — the property `CLAUDE.md` demands of an opt-out, since
3335/// the defect this branch exists to catch is structurally incapable of
3336/// producing its proof.
3337///
3338/// There is no maximum run and there is not going to be one: a route's length is
3339/// per-campaign geometry, never a standard (spec-0053 §7).
3340fn size_classes(placed: &[Placed<'_>], d: &mut Vec<Diagnostic>) {
3341 for p in placed {
3342 let Some(class) = p.class else {
3343 continue; // `DW0812` refused the name.
3344 };
3345 let (kind, mut bad) = match class {
3346 PlaceClass::Size(sc) => {
3347 let mut bad: Vec<String> = Vec::new();
3348 for (axis, name) in [(0usize, "x"), (1, "z")] {
3349 let e = p.plan.extent[axis].get();
3350 if e < sc.min_footprint[axis] || e > sc.max_footprint[axis] {
3351 bad.push(format!(
3352 "{e} blocks on {name}, outside the class's {}..{}",
3353 sc.min_footprint[axis], sc.max_footprint[axis]
3354 ));
3355 }
3356 }
3357 ("size", bad)
3358 }
3359 PlaceClass::Way(w) => {
3360 let mut bad: Vec<String> = Vec::new();
3361 let (dx, dz) = (p.plan.extent[0].get(), p.plan.extent[1].get());
3362 let (width, run) = (dx.min(dz), dx.max(dz));
3363 let axis = if dx <= dz { "x" } else { "z" };
3364 if width < w.min_width || width > w.max_width {
3365 bad.push(format!(
3366 "a cross-section of {width} blocks (its shorter extent, on {axis}), \
3367 outside the class's {}..{}",
3368 w.min_width, w.max_width
3369 ));
3370 }
3371 if run <= w.max_width {
3372 bad.push(format!(
3373 "a run of {run} blocks, which does not exceed the class's widest \
3374 cross-section of {}. A way is a place that is longer than it is wide \
3375 by kind and not by margin, so this box is a room — give it a \
3376 `size_class` instead, or make it longer",
3377 w.max_width
3378 ));
3379 }
3380 ("way", bad)
3381 }
3382 };
3383 if let Ceiling::Clearance(c) = p.plan.ceiling
3384 && c.get() < class.min_clearance()
3385 {
3386 bad.push(format!(
3387 "{c} cells of headroom, under the class's minimum of {}",
3388 class.min_clearance()
3389 ));
3390 }
3391 if bad.is_empty() {
3392 continue;
3393 }
3394 d.push(Diagnostic::error(
3395 DW_SIZE_CLASS,
3396 "site-plan",
3397 format!("/content/boxes/{}", p.index),
3398 format!(
3399 "the box for `{node}` is not built to its declared {kind} class: {bad}. The \
3400 class is the vocabulary the graph chose this place's scale in, and this is the \
3401 one place it becomes geometry — either build the box to it, or declare the \
3402 place a different class in the layout graph and say so there.",
3403 node = p.plan.node,
3404 bad = bad.join("; "),
3405 ),
3406 ));
3407 }
3408}
3409
3410// ---------------------------------------------------------------------------
3411// Seams: allocated on a face both boxes already have
3412// ---------------------------------------------------------------------------
3413
3414/// The rectangle two boxes have in common on one face, in the face's own two
3415/// in-plane world axes, plus the plane the wall between them stands in.
3416#[derive(Debug, Clone, Copy)]
3417struct SharedFace {
3418 plane: i64,
3419 u: (i64, i64),
3420 v: (i64, i64),
3421 u_axis: &'static str,
3422 v_axis: &'static str,
3423}
3424
3425/// Why two boxes do not share the declared face.
3426#[derive(Debug, Clone)]
3427enum NotShared {
3428 /// They are not neighbours across it: the gap is `gap` cells where the one
3429 /// wall they would have in common needs exactly 1.
3430 NotAdjacent { gap: i64 },
3431 /// They are neighbours, but the face they would share is empty because their
3432 /// spans miss each other on one of the two in-plane axes.
3433 NoCommonArea { axis: &'static str },
3434 /// One of them is sky-open with no stated headroom, so it has no ceiling or
3435 /// floor plane for a horizontal seam to sit in.
3436 NoPlane { which: &'static str },
3437}
3438
3439/// Do these two boxes share `face` **of `a`**, and where?
3440///
3441/// A shared face is a **one-cell gap** — the wall the two places have in common,
3442/// which the derivation writes once. See [`Placed`] for why the box is the play
3443/// space rather than the play space plus its shell.
3444/// The geometry a shared-face question needs of one box: its footprint and its
3445/// vertical span, when it has one.
3446///
3447/// A tiny value rather than `&Placed` so that the **one** implementation of "do
3448/// these two boxes share this face" serves both readers of the resolved plan:
3449/// the stage-4 checks, which hold a partially-resolved box, and the derivation
3450/// and battery in the compiler, which hold a [`PlacedBox`]. A second copy of
3451/// this arithmetic is how a plan-time green and a byte-time green come to be
3452/// about different walls.
3453#[derive(Clone, Copy)]
3454struct FaceSide {
3455 foot: [i64; 4],
3456 y: Option<(i64, i64)>,
3457}
3458
3459impl Placed<'_> {
3460 fn side(&self) -> FaceSide {
3461 FaceSide {
3462 foot: self.foot,
3463 y: self.y_span(),
3464 }
3465 }
3466}
3467
3468impl PlacedBox {
3469 fn side(&self) -> FaceSide {
3470 let (lo, hi) = self.space();
3471 FaceSide {
3472 foot: self.foot,
3473 y: Some((lo[1], hi[1])),
3474 }
3475 }
3476}
3477
3478/// [`shared_face`] over two fully resolved boxes.
3479fn shared_face_of(a: &PlacedBox, b: &PlacedBox, face: Face) -> Result<SharedFace, NotShared> {
3480 shared_face(a.side(), b.side(), face)
3481}
3482
3483fn shared_face(a: FaceSide, b: FaceSide, face: Face) -> Result<SharedFace, NotShared> {
3484 let horizontal_pair = |plane: i64, u: (i64, i64), v: (i64, i64)| -> SharedFace {
3485 SharedFace {
3486 plane,
3487 u,
3488 v,
3489 u_axis: "x",
3490 v_axis: "z",
3491 }
3492 };
3493 match face {
3494 Face::East | Face::West | Face::South | Face::North => {
3495 // The axis the face's normal runs along, and the horizontal axis
3496 // that stays in the plane.
3497 let (normal, along) = match face {
3498 Face::East | Face::West => (0usize, 2usize),
3499 _ => (2usize, 0usize),
3500 };
3501 let a_span = span(a.foot, normal);
3502 let b_span = span(b.foot, normal);
3503 let positive = matches!(face, Face::East | Face::South);
3504 let (plane, gap) = if positive {
3505 (a_span.1 + 1, b_span.0 - a_span.1 - 1)
3506 } else {
3507 (a_span.0 - 1, a_span.0 - b_span.1 - 1)
3508 };
3509 if gap != SHARED_FACE_GAP_CELLS {
3510 return Err(NotShared::NotAdjacent { gap });
3511 }
3512 let a_along = span(a.foot, along);
3513 let b_along = span(b.foot, along);
3514 let u = overlap(a_along, b_along).ok_or(NotShared::NoCommonArea {
3515 axis: if along == 0 { "x" } else { "z" },
3516 })?;
3517 let (ya, yb) = match (a.y, b.y) {
3518 (Some(ya), Some(yb)) => (ya, yb),
3519 (None, _) => return Err(NotShared::NoPlane { which: "a" }),
3520 (_, None) => return Err(NotShared::NoPlane { which: "b" }),
3521 };
3522 let v = overlap(ya, yb).ok_or(NotShared::NoCommonArea { axis: "y" })?;
3523 Ok(SharedFace {
3524 plane,
3525 u,
3526 v,
3527 u_axis: if along == 0 { "x" } else { "z" },
3528 v_axis: "y",
3529 })
3530 }
3531 Face::Up | Face::Down => {
3532 let (Some(ya), Some(yb)) = (a.y, b.y) else {
3533 return Err(NotShared::NoPlane {
3534 which: if a.y.is_none() { "a" } else { "b" },
3535 });
3536 };
3537 let (plane, gap) = if face == Face::Up {
3538 (ya.1 + 1, yb.0 - ya.1 - 1)
3539 } else {
3540 (ya.0 - 1, ya.0 - yb.1 - 1)
3541 };
3542 if gap != SHARED_FACE_GAP_CELLS {
3543 return Err(NotShared::NotAdjacent { gap });
3544 }
3545 let u = overlap(span(a.foot, 0), span(b.foot, 0))
3546 .ok_or(NotShared::NoCommonArea { axis: "x" })?;
3547 let v = overlap(span(a.foot, 2), span(b.foot, 2))
3548 .ok_or(NotShared::NoCommonArea { axis: "z" })?;
3549 Ok(horizontal_pair(plane, u, v))
3550 }
3551 }
3552}
3553
3554/// One seam and everything already resolved about it: the two places it joins,
3555/// the connection it allocates, and the face they share. Carried as one value so
3556/// each rule below takes the seam and its context rather than eight positional
3557/// arguments — the shape a `clippy::too_many_arguments` allow would otherwise
3558/// have papered over.
3559struct SeamCtx<'a> {
3560 index: usize,
3561 seam: &'a Seam,
3562 edge: &'a Edge,
3563 a: &'a Placed<'a>,
3564 b: &'a Placed<'a>,
3565 face: SharedFace,
3566 /// The crossing's low corner on the face's own two world axes, from the
3567 /// packing — `[along, sill]` on a wall, `[x, z]` through a floor.
3568 at: [i64; 2],
3569}
3570
3571/// `DW0828`–`DW0831`: every seam sits on a face its two boxes share, at cells
3572/// that face has, through a standard opening a body can use, and — where the two
3573/// places are on different planes — by a climb or a fall the standards allow.
3574fn seams(
3575 plan: &SitePlanContent,
3576 graph: &LayoutGraphContent,
3577 placed: &[Placed<'_>],
3578 packed: &Packed,
3579 table: &Metrics,
3580 reads: &mut Reads,
3581 d: &mut Vec<Diagnostic>,
3582) {
3583 let by_node: BTreeMap<&str, &Placed<'_>> =
3584 placed.iter().map(|p| (p.plan.node.0.as_str(), p)).collect();
3585 let edges: BTreeMap<&str, &Edge> = graph.edges.iter().map(|e| (e.id().0.as_str(), e)).collect();
3586
3587 for (i, s) in plan.seams.iter().enumerate() {
3588 if packed.refused.contains(&i) {
3589 continue; // the packing refused this seam by name already.
3590 }
3591 let Some(edge) = edges.get(s.edge.0.as_str()) else {
3592 continue; // `DW0824` refused the reference.
3593 };
3594 if !edge.has_seam() {
3595 continue; // `DW0824` said this carries a sightline.
3596 }
3597 let (Some(a), Some(b)) = (
3598 by_node.get(edge.a().0.as_str()).copied(),
3599 by_node.get(edge.b().0.as_str()).copied(),
3600 ) else {
3601 continue; // `DW0824` reported the missing box, or nothing placed it.
3602 };
3603 let Some(at) = packed.seam_at[i] else {
3604 continue; // an end has no plane; `DW0112` said so.
3605 };
3606
3607 let face = match shared_face(a.side(), b.side(), s.face) {
3608 Ok(f) => f,
3609 Err(why) => {
3610 d.push(not_shared(i, s, edge, a, b, &why));
3611 continue;
3612 }
3613 };
3614
3615 let ctx = SeamCtx {
3616 index: i,
3617 seam: s,
3618 edge,
3619 a,
3620 b,
3621 face,
3622 at,
3623 };
3624
3625 // `DW0876`, first: a seam that does not state exactly one kind of
3626 // connection has no crossing for any rule below to judge, and telling
3627 // an author both that and what the crossing they did not state would
3628 // have meant prescribes two repairs for one mistake.
3629 if !contact_declaration(&ctx, table, reads, d) {
3630 continue;
3631 }
3632
3633 let opening = if s.contact.is_some() {
3634 // A contact has no opening name to resolve and no single sill, so
3635 // `DW0829` does not run over it. That is stated rather than
3636 // shoehorned: calling a 55-cell front a door would make every
3637 // downstream door check wrong (spec-0053 §4).
3638 None
3639 } else {
3640 match table.resolve(
3641 MetricKind::Opening,
3642 s.opening.as_deref().unwrap_or_default(),
3643 ) {
3644 Ok(e) => match e.value(reads) {
3645 MetricValue::Opening(o) => Some(*o),
3646 _ => continue,
3647 },
3648 Err(unknown) => {
3649 d.push(unknown.diagnostic("site-plan", &format!("/content/seams/{i}/opening")));
3650 continue;
3651 }
3652 }
3653 };
3654
3655 if let Some(opening) = opening {
3656 opening_fits(&ctx, opening, d);
3657 }
3658 match edge {
3659 Edge::Stair { .. } => stair(&ctx, table, reads, d),
3660 Edge::Drop { falls, .. } => drop_seam(&ctx, *falls, table, reads, d),
3661 Edge::Walk { .. } | Edge::Barred { .. } => {
3662 if let Some(opening) = opening {
3663 sill(&ctx, opening, d);
3664 }
3665 }
3666 Edge::Carry { .. } | Edge::Vision { .. } => {}
3667 }
3668 if matches!(edge, Edge::Stair { .. }) && s.stair_in.is_none() {
3669 d.push(Diagnostic::error(
3670 DW_STAIR_PITCH,
3671 "site-plan",
3672 format!("/content/seams/{i}"),
3673 format!(
3674 "the seam for stair `{id}` does not say which place hosts its treads. A \
3675 stair is massing, and massing stands somewhere: name `{a}` or `{b}` in \
3676 `stair_in`, so that the run it costs comes out of a footprint the plan has \
3677 already allocated rather than out of whatever space happens to be left.",
3678 id = s.edge,
3679 a = edge.a(),
3680 b = edge.b(),
3681 ),
3682 ));
3683 }
3684 }
3685}
3686
3687/// `DW0828`, with the arithmetic that produced it.
3688fn not_shared(
3689 i: usize,
3690 s: &Seam,
3691 edge: &Edge,
3692 a: &Placed<'_>,
3693 b: &Placed<'_>,
3694 why: &NotShared,
3695) -> Diagnostic {
3696 let detail = match why {
3697 NotShared::NotAdjacent { gap } if *gap < 0 => format!(
3698 "they overlap by {} cell(s) across it rather than standing one apart",
3699 -gap
3700 ),
3701 NotShared::NotAdjacent { gap } => format!(
3702 "there are {gap} cells between them across that face where a shared wall is exactly \
3703 {SHARED_FACE_GAP_CELLS}"
3704 ),
3705 NotShared::NoCommonArea { axis } => format!(
3706 "they are neighbours across it, but their spans on {axis} miss each other entirely, \
3707 so the face they share has no area to cut an opening in"
3708 ),
3709 NotShared::NoPlane { which } => format!(
3710 "the `{which}` end is sky-open with no stated headroom, so it has no ceiling or floor \
3711 plane for a horizontal seam to sit in"
3712 ),
3713 };
3714 Diagnostic::error(
3715 DW_SEAM_NOT_SHARED,
3716 "site-plan",
3717 format!("/content/seams/{i}/face"),
3718 format!(
3719 "the seam for `{id}` is declared on the {face} face of `{an}`, and `{an}` and `{bn}` \
3720 do not share it: {detail}. **A seam is allocated on a face both boxes already \
3721 have** — that is the whole of why the plan places it while both are still free to \
3722 move, instead of two finished places discovering later that they cannot mate. \
3723 `{an}` is x {ax0}..{ax1}, z {az0}..{az1} at floor {af}; `{bn}` is x {bx0}..{bx1}, \
3724 z {bz0}..{bz1} at floor {bf}. Move one box against the other, or put the seam on \
3725 the face they really share.",
3726 id = s.edge,
3727 face = s.face.as_str(),
3728 an = edge.a(),
3729 bn = edge.b(),
3730 ax0 = a.x0(),
3731 ax1 = a.x1(),
3732 az0 = a.z0(),
3733 az1 = a.z1(),
3734 af = a.floor,
3735 bx0 = b.x0(),
3736 bx1 = b.x1(),
3737 bz0 = b.z0(),
3738 bz1 = b.z1(),
3739 bf = b.floor,
3740 ),
3741 )
3742}
3743
3744/// **`DW0876`**: this seam states exactly one kind of
3745/// connection, and if it is a contact, one this engine builds (spec-0053 §4).
3746///
3747/// Returns `false` when the seam has no usable crossing, in which case the
3748/// caller stops: everything below reads the crossing rectangle.
3749fn contact_declaration(
3750 ctx: &SeamCtx<'_>,
3751 table: &Metrics,
3752 reads: &mut Reads,
3753 d: &mut Vec<Diagnostic>,
3754) -> bool {
3755 let (i, s) = (ctx.index, ctx.seam);
3756 let mut refuse = |what: String, remedy: String| {
3757 d.push(Diagnostic::error(
3758 DW_CONTACT,
3759 "site-plan",
3760 format!("/content/seams/{i}"),
3761 format!(
3762 "the seam for `{edge}` {what}. To fix it, {remedy}.",
3763 edge = s.edge,
3764 ),
3765 ));
3766 };
3767
3768 // ---- Shape 1: exactly one kind.
3769 match (s.opening.as_ref(), s.contact.as_ref()) {
3770 (Some(o), Some(_)) => {
3771 refuse(
3772 format!(
3773 "declares BOTH an `opening` (`{o}`) and a `contact` — a hand-off is one \
3774 kind or the other"
3775 ),
3776 "delete whichever this is not. A portal allocates the cells a body crosses \
3777 at and every one of them must be passable; a contact is a front along which \
3778 two places simply meet and needs only one crossable column. The derivation \
3779 builds them differently and the byte observer measures them differently, so \
3780 there is no world in which a seam is both"
3781 .to_string(),
3782 );
3783 return false;
3784 }
3785 (None, None) => {
3786 refuse(
3787 "declares neither an `opening` nor a `contact`, so it states no way across"
3788 .to_string(),
3789 format!(
3790 "give it one. A doorway is `\"opening\": \"<name>\"` — defined \
3791 standards: {names}. A front where the two places simply meet is \
3792 `\"contact\": {{}}`, which spans from `at` to the far edge of the \
3793 shared face",
3794 names = table.names_of(MetricKind::Opening).join(", "),
3795 ),
3796 );
3797 return false;
3798 }
3799 (Some(_), None) => return true,
3800 (None, Some(_)) => {}
3801 }
3802
3803 // ---- Shape 4: the classes a contact may carry.
3804 //
3805 // `walk` and `drop` only. A rim falling to a lower court is a genuine broad
3806 // hand-off, so `drop` is in; `stair`, `barred` and `vision` are excluded
3807 // until a campaign brief demands one (spec-0053 §4, the falsifier re-armed).
3808 if !matches!(ctx.edge, Edge::Walk { .. } | Edge::Drop { .. }) {
3809 refuse(
3810 format!(
3811 "is a contact on a `{class}` connection, and a contact carries `walk` or \
3812 `drop` only",
3813 class = ctx.edge.class(),
3814 ),
3815 "give the seam a standard `opening`, or declare the connection `walk` or \
3816 `drop` in the layout graph. A stair needs a run and a pitch, a barred door \
3817 needs a gate region that seals and clears, and a sightline is not a crossing \
3818 at all — none of the three is a thing a front can be, and this engine does \
3819 not have them as contacts until a campaign brief demands one"
3820 .to_string(),
3821 );
3822 return false;
3823 }
3824
3825 // ---- Shape 3: wider than the broadest standard opening.
3826 //
3827 // The floor is derived from the standard set, so anything at or under it
3828 // could have been a portal. That is what makes it a demand the defect
3829 // cannot supply: a door declared a contact is refused by its own width.
3830 let Some(floor) = table.broadest_opening_width(reads) else {
3831 return false; // `Metrics::self_check` reports a table with no openings.
3832 };
3833 let (u_span, v_span) = (ctx.face.u, ctx.face.v);
3834 let (u_hi, v_hi) = crossing_hi(ctx);
3835 let width = u_hi - ctx.at[0] + 1;
3836 if width <= i64::from(floor) {
3837 refuse(
3838 format!(
3839 "is a contact {width} cell(s) wide, which is not wider than the broadest \
3840 standard opening ({floor} cells)"
3841 ),
3842 format!(
3843 "widen the span, or declare it a portal — anything this narrow could have \
3844 been one, and a doorway called a contact would dodge the standard set \
3845 while every downstream door check went on being wrong about it. Defined \
3846 openings: {names}",
3847 names = table.names_of(MetricKind::Opening).join(", "),
3848 ),
3849 );
3850 return false;
3851 }
3852
3853 // ---- Shape 2: the span lies on the shared face.
3854 let mut off: Vec<String> = Vec::new();
3855 if ctx.at[0] < u_span.0 || u_hi > u_span.1 {
3856 off.push(format!(
3857 "{}..{} on {}, against the face's {}..{}",
3858 ctx.at[0], u_hi, ctx.face.u_axis, u_span.0, u_span.1
3859 ));
3860 }
3861 if ctx.at[1] < v_span.0 || v_hi > v_span.1 {
3862 off.push(format!(
3863 "{}..{} on {}, against the face's {}..{}",
3864 ctx.at[1], v_hi, ctx.face.v_axis, v_span.0, v_span.1
3865 ));
3866 }
3867 if !off.is_empty() {
3868 refuse(
3869 format!(
3870 "is a contact whose span leaves the face the two boxes share: {}",
3871 off.join("; ")
3872 ),
3873 "move `at` onto the shared face, or shorten `contact.extent` — the span is \
3874 where the derivation writes no wall, and a span running off the face would \
3875 ask it to open a wall that is not there. Omitting `contact.extent` runs the \
3876 span from `at` to the far edge of the face, which never leaves it"
3877 .to_string(),
3878 );
3879 return false;
3880 }
3881 true
3882}
3883
3884/// The far corner of a seam's crossing rectangle, on the face's own two in-plane
3885/// axes — [`contact_extent`] resolved against the seam's own anchor, so this
3886/// rule and the derivation describe one rectangle.
3887fn crossing_hi(ctx: &SeamCtx<'_>) -> (i64, i64) {
3888 let e = contact_extent(ctx.seam, ctx.at, &ctx.face);
3889 (ctx.at[0] + e[0] - 1, ctx.at[1] + e[1] - 1)
3890}
3891
3892/// `DW0828`'s anchor half and `DW0829`'s geometric half: the opening's cells are
3893/// cells the shared face has.
3894fn opening_fits(ctx: &SeamCtx<'_>, opening: crate::metrics::Opening, d: &mut Vec<Diagnostic>) {
3895 let (i, s, edge, face, at) = (ctx.index, ctx.seam, ctx.edge, &ctx.face, ctx.at);
3896 let anchor_in =
3897 at[0] >= face.u.0 && at[0] <= face.u.1 && at[1] >= face.v.0 && at[1] <= face.v.1;
3898 if !anchor_in {
3899 d.push(Diagnostic::error(
3900 DW_SEAM_NOT_SHARED,
3901 "site-plan",
3902 format!("/content/seams/{i}/at"),
3903 format!(
3904 "the seam for `{id}` is anchored at {ua} {u}, {va} {v}, which is not on the face \
3905 `{an}` and `{bn}` share — that face runs {ua} {u0}..{u1} by {va} {v0}..{v1} in \
3906 the plane at {plane}. `at` names the opening's low corner in the face's own two \
3907 axes, so a corner off the face allocates the seam nowhere.",
3908 id = s.edge,
3909 an = edge.a(),
3910 bn = edge.b(),
3911 ua = face.u_axis,
3912 va = face.v_axis,
3913 u = at[0],
3914 v = at[1],
3915 u0 = face.u.0,
3916 u1 = face.u.1,
3917 v0 = face.v.0,
3918 v1 = face.v.1,
3919 plane = face.plane,
3920 ),
3921 ));
3922 return;
3923 }
3924 let u_hi = at[0] + i64::from(opening.width) - 1;
3925 let v_hi = at[1] + i64::from(opening.height) - 1;
3926 if u_hi <= face.u.1 && v_hi <= face.v.1 {
3927 return;
3928 }
3929 d.push(Diagnostic::error(
3930 DW_SEAM_OPENING,
3931 "site-plan",
3932 format!("/content/seams/{i}/opening"),
3933 format!(
3934 "the `{name}` opening ({w}x{h}) does not fit on the face `{an}` and `{bn}` share. \
3935 Anchored at {ua} {u}, {va} {v} it would run to {ua} {u_hi}, {va} {v_hi}, and the \
3936 shared face ends at {ua} {u1}, {va} {v1}. Move the anchor, choose a narrower \
3937 standard opening, or grow the overlap between the two boxes — the standard set is \
3938 the vocabulary, so the opening is never quietly cropped to fit.",
3939 name = s.opening.as_deref().unwrap_or_default(),
3940 w = opening.width,
3941 h = opening.height,
3942 an = edge.a(),
3943 bn = edge.b(),
3944 ua = face.u_axis,
3945 va = face.v_axis,
3946 u = at[0],
3947 v = at[1],
3948 u1 = face.u.1,
3949 v1 = face.v.1,
3950 ),
3951 ));
3952}
3953
3954/// `DW0829`'s step-rule half: a body standing on the floor of a side it enters
3955/// from can get onto the sill.
3956fn sill(ctx: &SeamCtx<'_>, opening: crate::metrics::Opening, d: &mut Vec<Diagnostic>) {
3957 let (i, s, edge, a, b, face) = (ctx.index, ctx.seam, ctx.edge, ctx.a, ctx.b, &ctx.face);
3958 if face.v_axis != "y" {
3959 return; // a horizontal seam has no sill; the fall or the treads own it.
3960 }
3961 let sources: Vec<(&NodeId, &Placed<'_>)> = match edge.direction() {
3962 Some(crate::layout::Direction::AToB) => vec![(edge.a(), a)],
3963 Some(crate::layout::Direction::BToA) => vec![(edge.b(), b)],
3964 None => vec![(edge.a(), a), (edge.b(), b)],
3965 };
3966 let max_rise = MAX_JUMP_RISE_16 / crate::metrics::FULL_16;
3967 for (name, p) in sources {
3968 let rise = ctx.at[1] - p.floor;
3969 if rise <= max_rise {
3970 continue;
3971 }
3972 d.push(Diagnostic::error(
3973 DW_SEAM_OPENING,
3974 "site-plan",
3975 format!("/content/seams/{i}/at"),
3976 format!(
3977 "the seam for `{id}` has its sill at y {sill}, {rise} blocks over the floor of \
3978 `{name}` at y {floor}, and a body reaches at most {max_rise} block(s) by \
3979 jumping ({j}/16 of vanilla's apex). A body entering from `{name}` cannot get \
3980 into the opening at all, so the connection the graph declares is not one. The \
3981 sill is the higher of the two floors: bring the floors within a step of each \
3982 other, or declare the connection a `stair` and let the treads carry the climb. \
3983 (The opening is {w}x{h}.)",
3984 id = s.edge,
3985 sill = ctx.at[1],
3986 j = MAX_JUMP_RISE_16,
3987 floor = p.floor,
3988 w = opening.width,
3989 h = opening.height,
3990 ),
3991 ));
3992 }
3993}
3994
3995/// `DW0830`: the stair the plan allocated can be built at a standard pitch,
3996/// inside the box the plan said hosts it.
3997fn stair(ctx: &SeamCtx<'_>, table: &Metrics, reads: &mut Reads, d: &mut Vec<Diagnostic>) {
3998 let (i, s, edge, a, b, face) = (ctx.index, ctx.seam, ctx.edge, ctx.a, ctx.b, &ctx.face);
3999 let rise = b.floor - a.floor;
4000 if rise == 0 {
4001 d.push(Diagnostic::error(
4002 DW_STAIR_PITCH,
4003 "site-plan",
4004 format!("/content/seams/{i}"),
4005 format!(
4006 "`{id}` is a stair, and `{an}` and `{bn}` are both on plane y {f} — so it climbs \
4007 nothing. A stair's rise is not authored here: it is the difference between the \
4008 two floors the plan has already chosen, which means a stair between two places \
4009 at one level is a walk that has been called a stair. Move one floor, or declare \
4010 the connection a `walk`.",
4011 id = s.edge,
4012 an = edge.a(),
4013 bn = edge.b(),
4014 f = a.floor,
4015 ),
4016 ));
4017 return;
4018 }
4019 let Some(host_id) = &s.stair_in else {
4020 return; // the missing declaration is reported by `seams`.
4021 };
4022 // **The treads stand in the LOWER place**, and that is geometry rather than
4023 // taste: a stair is a stack of courses rising off a walk plane, and the only
4024 // walk plane it can rise off is the lower of the two. Hosting it in the
4025 // upper place asks for a stack that starts at that place's floor and has to
4026 // reach a level *below* it, which is not a stair — it is a hole with treads
4027 // drawn in the air under it.
4028 //
4029 // Found by building. This code checked only that the host affords the RUN,
4030 // so a plan naming the upper place reached green at stage 4 and the
4031 // derivation then laid a mound on the wrong side of the opening; the
4032 // stage-5 observer caught it as a seam whose hole was still solid, which is
4033 // the right refusal for the wrong defect. `stair_in` stays authored rather
4034 // than derived because it says WHICH of the two footprints pays for the run
4035 // when both are candidates — but when only one can be, saying the other is
4036 // a refusal.
4037 let (low, high) = if b.floor > a.floor {
4038 (edge.a(), edge.b())
4039 } else {
4040 (edge.b(), edge.a())
4041 };
4042 if host_id == high {
4043 d.push(Diagnostic::error(
4044 DW_STAIR_PITCH,
4045 "site-plan",
4046 format!("/content/seams/{i}/stair_in"),
4047 format!(
4048 "the stair for `{id}` hosts its treads in `{high}`, which is the HIGHER of the two \
4049 places (`{an}` stands at y {af}, `{bn}` at y {bf}). Treads rise off a walk plane, \
4050 and the only plane this stair can rise off is the lower one — massing in the \
4051 upper place would have to start at that place's floor and reach a level beneath \
4052 it, which is not a stair. Host it in `{low}`, and check that `{low}` affords the \
4053 run: a stair costs its footprint, and moving the host moves who pays.",
4054 id = s.edge,
4055 an = edge.a(),
4056 bn = edge.b(),
4057 af = a.floor,
4058 bf = b.floor,
4059 ),
4060 ));
4061 return;
4062 }
4063 let host = if host_id == edge.a() { a } else { b };
4064 // **The run is measured the way the derivation lays it**, by the function
4065 // that lays it — [`stair_run`]. What this check used to measure instead was
4066 // the seam's rise against the host's whole extent, and neither is what a
4067 // tread run costs: the courses carry a body to the OPENING, and through a
4068 // pierced floor they leave along one side of the hole. See [`stair_run`] for
4069 // the plan that reached green at `needs 8, affords 8` and built no stair.
4070 let Some((_, extent)) = crossing_rect(s, ctx.at, face, table, reads) else {
4071 return; // `DW0812` refused the opening; there is no rectangle to measure.
4072 };
4073 let Some(run) = stair_run(
4074 host.floor,
4075 host.foot,
4076 normal_axis_of(s.face),
4077 face.plane,
4078 crossing_aabb(s, ctx.at, face, extent),
4079 ) else {
4080 return; // no run to lay: the higher host is refused above, the stray hole by `DW0828`.
4081 };
4082 let run_axis = if run.run_axis == 0 { "x" } else { "z" };
4083 if gentlest_pitch(table, reads, run.climb, run.available).is_some() {
4084 return; // some standard pitch fits.
4085 }
4086 let Some((name, needed)) = tightest_pitch(table, reads, run.climb) else {
4087 return; // the table defines no pitch; `Metrics::self_check` owns that.
4088 };
4089 let carries = if run.climb == rise.abs() {
4090 String::new()
4091 } else {
4092 format!(
4093 " The treads carry {climb}, not {rise}: they rise off `{host_id}`'s floor at \
4094 {hf} and stop at {target}, which is where the opening puts a body.",
4095 climb = run.climb,
4096 rise = rise.abs(),
4097 hf = host.floor,
4098 target = host.floor + run.climb,
4099 )
4100 };
4101 d.push(Diagnostic::error(
4102 DW_STAIR_PITCH,
4103 "site-plan",
4104 format!("/content/seams/{i}"),
4105 format!(
4106 "the stair for `{id}` climbs {rise} block(s) between `{an}` (floor {af}) and `{bn}` \
4107 (floor {bf}), and no standard pitch fits inside `{host_id}`. The tightest standard \
4108 is `{name}`, which needs {needed} block(s) of run for a climb of {climb}, and \
4109 `{host_id}` affords {available} of run on {run_axis}.{carries} Give the host a \
4110 longer footprint on that axis, move the opening so the run has more room beside it, \
4111 host the stair in the other place, or bring the two floors closer together — the \
4112 pitches are standards, so a steeper one is not on offer.",
4113 id = s.edge,
4114 an = edge.a(),
4115 bn = edge.b(),
4116 af = a.floor,
4117 bf = b.floor,
4118 rise = rise.abs(),
4119 climb = run.climb,
4120 available = run.available,
4121 ),
4122 ));
4123}
4124
4125/// `DW0831`: a designed drop falls the way it says it falls, and no further than
4126/// the policy allows.
4127fn drop_seam(
4128 ctx: &SeamCtx<'_>,
4129 falls: crate::layout::Direction,
4130 table: &Metrics,
4131 reads: &mut Reads,
4132 d: &mut Vec<Diagnostic>,
4133) {
4134 let (i, s, edge, a, b) = (ctx.index, ctx.seam, ctx.edge, ctx.a, ctx.b);
4135 let (from, from_p, to, to_p) = match falls {
4136 crate::layout::Direction::AToB => (edge.a(), a, edge.b(), b),
4137 crate::layout::Direction::BToA => (edge.b(), b, edge.a(), a),
4138 };
4139 let depth = from_p.floor - to_p.floor;
4140 if depth <= 0 {
4141 d.push(Diagnostic::error(
4142 DW_DROP_POLICY,
4143 "site-plan",
4144 format!("/content/seams/{i}"),
4145 format!(
4146 "`{id}` falls from `{from}` (floor {ff}) into `{to}` (floor {tf}), which is \
4147 {what}. A drop is one-way because a body cannot climb back up the way it came, \
4148 and that is only true going down — this one is a mislabelled stair. Swap the \
4149 declared direction, move the floors, or declare the connection a `stair`.",
4150 id = s.edge,
4151 ff = from_p.floor,
4152 tf = to_p.floor,
4153 what = if depth == 0 {
4154 "the same plane".to_string()
4155 } else {
4156 format!("{} block(s) HIGHER", -depth)
4157 },
4158 ),
4159 ));
4160 return;
4161 }
4162 let Some(cap) = table.max_designed_drop_blocks(reads) else {
4163 return;
4164 };
4165 if depth <= i64::from(cap) {
4166 return;
4167 }
4168 d.push(Diagnostic::error(
4169 DW_DROP_POLICY,
4170 "site-plan",
4171 format!("/content/seams/{i}"),
4172 format!(
4173 "`{id}` drops {depth} blocks from `{from}` into `{to}`, and the designed-drop policy \
4174 caps a declared fall at {cap}. This is a **policy** cap and it is deliberately far \
4175 tighter than what a body survives: a drop is a decision about the shape of the map, \
4176 and it should not also be a decision about the party's health. Bring the two floors \
4177 closer, or break the fall with a place between them.",
4178 id = s.edge,
4179 ),
4180 ));
4181}
4182
4183// ---------------------------------------------------------------------------
4184// Identities: the plan held to the brief's own numbers
4185// ---------------------------------------------------------------------------
4186
4187/// What one measure came out at, or why it could not be taken.
4188enum Measured {
4189 Value(f64),
4190 Unresolved(Diagnostic),
4191}
4192
4193/// `DW0833` and `DW0834`: the brief's numbers still hold once the boxes are
4194/// drawn, and the binding that holds them is not empty.
4195///
4196/// `DW0834` is a **warning**, not an error, and the difference is deliberate: a
4197/// deliberately minimal plan — a fixture, a first sketch — is a legitimate
4198/// thing to hold, and refusing it would make the smallest useful document
4199/// uncompilable. What it may not be is *silent*, so the empty side is named on
4200/// every run and is a finding for the round summary.
4201///
4202/// `DW0833` runs here over the plan. **Its second call site is the built
4203/// world**, where the same rule recomputes the same measures from assembled
4204/// bytes so that a derivation defect which moved a datum cannot hide behind a
4205/// plan-time green. That site belongs to the round that builds the blockout;
4206/// nothing here approximates it.
4207fn identities(
4208 c: &Campaign,
4209 plan: &SitePlanContent,
4210 placed: &[Placed<'_>],
4211 d: &mut Vec<Diagnostic>,
4212) {
4213 let facts: BTreeMap<&str, &crate::layout::BriefFact> = c
4214 .geometry_brief
4215 .as_ref()
4216 .map(|b| {
4217 b.content
4218 .facts
4219 .iter()
4220 .map(|f| (f.id.0.as_str(), f))
4221 .collect()
4222 })
4223 .unwrap_or_default();
4224
4225 if facts.is_empty() || plan.identities.is_empty() {
4226 let empty = match (facts.is_empty(), plan.identities.is_empty()) {
4227 (true, true) => "the brief states no fact and the plan declares no identity",
4228 (true, false) => "the brief states no fact",
4229 _ => "the plan declares no identity",
4230 };
4231 d.push(Diagnostic::warning(
4232 DW_IDENTITY_EMPTY,
4233 "site-plan",
4234 "/content/identities",
4235 format!(
4236 "the identity gate binds nothing: {empty}. This is what holds the whole map to \
4237 the design somebody wrote down — with either side empty, the plan may say \
4238 anything at all and every check above will still pass, because none of them \
4239 has an opinion about how big the map was meant to be. It is a warning rather \
4240 than a refusal so that a deliberately minimal plan stays compilable; it is \
4241 printed every run so that the emptiness is never quietly a pass."
4242 ),
4243 ));
4244 }
4245
4246 let by_node: BTreeMap<&str, &Placed<'_>> =
4247 placed.iter().map(|p| (p.plan.node.0.as_str(), p)).collect();
4248 let datums: BTreeMap<&str, i64> = plan.datums.iter().map(|x| (x.id.0.as_str(), x.y)).collect();
4249
4250 for (i, id) in plan.identities.iter().enumerate() {
4251 let Some(fact) = facts.get(id.fact.0.as_str()) else {
4252 d.push(Diagnostic::error(
4253 DW_PLAN_AGREEMENT,
4254 "site-plan",
4255 format!("/content/identities/{i}/fact"),
4256 format!(
4257 "this identity holds the map to `{f}`, which the geometry brief states no \
4258 fact for. An identity binds to a number the brief WROTE DOWN — that is what \
4259 makes it a design being kept rather than an assertion the plan makes about \
4260 itself.",
4261 f = id.fact,
4262 ),
4263 ));
4264 continue;
4265 };
4266 let measured = measure(&id.measure, plan, &by_node, &datums, i);
4267 let value = match measured {
4268 Measured::Value(v) => v,
4269 Measured::Unresolved(diag) => {
4270 d.push(diag);
4271 continue;
4272 }
4273 };
4274 if id.cmp.holds(value, fact.value) {
4275 continue;
4276 }
4277 d.push(Diagnostic::error(
4278 DW_IDENTITY_FALSE,
4279 "site-plan",
4280 format!("/content/identities/{i}"),
4281 format!(
4282 "the plan does not keep `{f}`: {what} measures {value}, and the brief asks for \
4283 {cmp} {want}{unit}. The brief's sentence was: \"{note}\". Either move the \
4284 geometry until the number is true, or change the brief's fact — in the brief, \
4285 where the design is written down, so that the change is a decision somebody \
4286 took rather than a plan that drifted.",
4287 f = id.fact,
4288 what = describe(&id.measure),
4289 cmp = id.cmp.as_str(),
4290 want = fact.value,
4291 unit = fact
4292 .unit
4293 .as_ref()
4294 .map(|u| format!(" {u}"))
4295 .unwrap_or_default(),
4296 note = fact.note,
4297 ),
4298 ));
4299 }
4300}
4301
4302/// Take one measure off the plan.
4303fn measure(
4304 m: &Measure,
4305 plan: &SitePlanContent,
4306 by_node: &BTreeMap<&str, &Placed<'_>>,
4307 datums: &BTreeMap<&str, i64>,
4308 i: usize,
4309) -> Measured {
4310 let missing_node = |node: &NodeId| {
4311 Measured::Unresolved(Diagnostic::error(
4312 DW_PLAN_AGREEMENT,
4313 "site-plan",
4314 format!("/content/identities/{i}/measure"),
4315 format!(
4316 "this identity measures `{node}`, which this plan embeds no box for. A measure \
4317 is taken off the geometry, so it can only name a place the plan actually put \
4318 somewhere."
4319 ),
4320 ))
4321 };
4322 match m {
4323 Measure::RegionExtent { axis } => {
4324 Measured::Value(f64::from(plan.region.extent[axis.index()].get()))
4325 }
4326 Measure::BoxExtent { node, axis } => match by_node.get(node.0.as_str()) {
4327 Some(p) => Measured::Value(f64::from(p.plan.extent[axis.index()].get())),
4328 None => missing_node(node),
4329 },
4330 Measure::BoxHeight { node } => match by_node.get(node.0.as_str()) {
4331 Some(p) => match p.clearance {
4332 Some(c) => Measured::Value(f64::from(c)),
4333 None => Measured::Unresolved(Diagnostic::error(
4334 DW_PLAN_AGREEMENT,
4335 "site-plan",
4336 format!("/content/identities/{i}/measure"),
4337 format!(
4338 "this identity measures the height of `{node}`, which is sky-open and \
4339 whose size class did not resolve — so the plan states no headroom for \
4340 it at all. Fix the class name the layout graph declares (`DW0812` names \
4341 it) and the height becomes the class's own minimum."
4342 ),
4343 )),
4344 },
4345 None => missing_node(node),
4346 },
4347 Measure::DistanceXz { from, to } => {
4348 let (Some(a), Some(b)) = (by_node.get(from.0.as_str()), by_node.get(to.0.as_str()))
4349 else {
4350 return missing_node(if by_node.contains_key(from.0.as_str()) {
4351 to
4352 } else {
4353 from
4354 });
4355 };
4356 let (ax, az) = a.centre_xz();
4357 let (bx, bz) = b.centre_xz();
4358 Measured::Value(((bx - ax).powi(2) + (bz - az).powi(2)).sqrt())
4359 }
4360 Measure::DatumY { datum } => match datums.get(datum.0.as_str()) {
4361 Some(y) => Measured::Value(*y as f64),
4362 None => Measured::Unresolved(Diagnostic::error(
4363 crate::codes::DANGLING_REF,
4364 "site-plan",
4365 format!("/content/identities/{i}/measure"),
4366 format!(
4367 "this identity measures `{datum}`, which this plan declares no `datums[]` \
4368 entry for."
4369 ),
4370 )),
4371 },
4372 }
4373}
4374
4375/// What a measure is, in a refusal's own words.
4376fn describe(m: &Measure) -> String {
4377 match m {
4378 Measure::RegionExtent { axis } => {
4379 format!("the region's extent on {}", axis.as_str())
4380 }
4381 Measure::BoxExtent { node, axis } => {
4382 format!("`{node}`'s footprint on {}", axis.as_str())
4383 }
4384 Measure::BoxHeight { node } => format!("`{node}`'s headroom"),
4385 Measure::DistanceXz { from, to } => {
4386 format!("the horizontal distance from `{from}` to `{to}`")
4387 }
4388 Measure::DatumY { datum } => format!("the plane `{datum}`"),
4389 }
4390}
4391
4392impl Axis {
4393 fn index(self) -> usize {
4394 match self {
4395 Axis::X => 0,
4396 Axis::Y => 1,
4397 Axis::Z => 2,
4398 }
4399 }
4400
4401 fn as_str(self) -> &'static str {
4402 match self {
4403 Axis::X => "x",
4404 Axis::Y => "y",
4405 Axis::Z => "z",
4406 }
4407 }
4408}
4409
4410impl PlanAxis {
4411 fn index(self) -> usize {
4412 match self {
4413 PlanAxis::X => 0,
4414 PlanAxis::Z => 1,
4415 }
4416 }
4417
4418 fn as_str(self) -> &'static str {
4419 match self {
4420 PlanAxis::X => "x",
4421 PlanAxis::Z => "z",
4422 }
4423 }
4424}
4425
4426impl VolumeRole {
4427 /// The keyword a refusal prints.
4428 #[must_use]
4429 pub fn as_str(self) -> &'static str {
4430 match self {
4431 VolumeRole::Massif => "massif",
4432 VolumeRole::Ground => "ground",
4433 VolumeRole::Clearance => "clearance",
4434 }
4435 }
4436}
4437
4438/// The plan's one lighting setting, range-checked exactly as an area's is — the
4439/// same code, because it is the same object being asked the same question.
4440fn lighting(plan: &SitePlanContent, d: &mut Vec<Diagnostic>) {
4441 let Some(l) = &plan.lighting else { return };
4442 if (1..=14).contains(&l.min_light) {
4443 return;
4444 }
4445 d.push(Diagnostic::error(
4446 crate::codes::LIGHTING_RANGE,
4447 "site-plan",
4448 "/content/lighting/min_light",
4449 format!(
4450 "`lighting.min_light` = {} is out of range — set it to a value in 1..=14 (7 is the \
4451 default)",
4452 l.min_light
4453 ),
4454 ));
4455}
4456
4457/// The floor a designed opening may never be chosen below: the cells a standing
4458/// body needs to pass at all.
4459///
4460/// Not a check of its own — [`Metrics::self_check`] already holds every standard
4461/// opening over it, and a second refusal here would be this module re-asking a
4462/// question the table has already answered about itself. It is re-exported so a
4463/// reader of `DW0829` can see what the standard set is bounded by.
4464#[must_use]
4465pub fn passable_opening_cells() -> (u32, u32) {
4466 (passable_width_cells(), passable_clearance_cells())
4467}
4468
4469#[cfg(test)]
4470mod tests {
4471 use super::*;
4472
4473 /// **The run beside a pierced floor is not the host's extent**, and this is
4474 /// the arithmetic the whole one-authority repair turns on.
4475 ///
4476 /// An eight-by-eight host with a three-wide hole cut through the ceiling
4477 /// above it: the treads leave along ONE side of the hole, so what they have
4478 /// is the room on the roomier side plus the hole's own width. Centre the
4479 /// hole and that is 3 + 3 = 6, not 8 — and the courses have to carry the
4480 /// body to the pierced plane, which is one below the floor of the place
4481 /// above it.
4482 ///
4483 /// The numbers are read off the geometry by hand rather than recomputed
4484 /// here, so a change to the rule shows up as a failure rather than as two
4485 /// arithmetics agreeing.
4486 #[test]
4487 fn a_run_beside_a_pierced_floor_is_the_room_on_one_side_plus_the_hole() {
4488 // Host x 4..11, z 4..11, walk plane y 56; the hole is cut at y 63 and
4489 // spans x 7..9 — three of the eight cells, three to the low side and
4490 // two to the high.
4491 let run = stair_run(56, [4, 11, 4, 11], 1, 63, ([7, 63, 6], [9, 63, 8]))
4492 .expect("the hole is over this host and the plane is above its floor");
4493 assert_eq!(run.climb, 7, "56 up to the pierced plane at 63");
4494 assert_eq!(run.run_axis, 0, "the host is square, so the run takes x");
4495 assert_eq!(
4496 run.available, 6,
4497 "three cells of room on the low side, plus the hole's three"
4498 );
4499 assert_eq!(
4500 run.start, 9,
4501 "course 0 stands under the far edge of the hole"
4502 );
4503 assert_eq!(run.step, -1, "and the run walks back into the room");
4504 }
4505
4506 /// The same host, the same hole, and the check and the derivation asking the
4507 /// same question: seven courses do not fit six cells, so no standard pitch
4508 /// does either. Measured against the host's extent it would — which is the
4509 /// green a plan used to reach before building nothing.
4510 #[test]
4511 fn no_standard_pitch_fits_a_climb_of_seven_in_six_cells_of_run() {
4512 let table = Metrics::table();
4513 let mut reads = Reads::default();
4514 assert!(gentlest_pitch(&table, &mut reads, 7, 6).is_none());
4515 assert!(gentlest_pitch(&table, &mut reads, 7, 7).is_some());
4516 assert_eq!(tightest_pitch(&table, &mut reads, 7), Some(("stair", 7)));
4517 }
4518
4519 /// Across a vertical face the run walks the whole footprint and the climb is
4520 /// the seam's own sill — the body stands at the sill and steps through.
4521 #[test]
4522 fn a_run_across_a_vertical_face_is_the_whole_footprint() {
4523 // Host x 4..11, z 4..11, walk plane y 64; the wall is at x 12 and the
4524 // opening's low corner is at y 72.
4525 let run = stair_run(64, [4, 11, 4, 11], 0, 12, ([12, 72, 6], [12, 74, 8]))
4526 .expect("the wall is beyond the host, so the run walks back from it");
4527 assert_eq!(run.climb, 8, "64 up to the sill at 72");
4528 assert_eq!(run.run_axis, 0);
4529 assert_eq!(run.available, 8, "the host's whole extent on x");
4530 assert_eq!(run.start, 11, "course 0 stands against the wall");
4531 assert_eq!(run.step, -1);
4532 }
4533}