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