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