Skip to main content

delvewright_dsl/siteplan/
measure.rs

1//! Identities: the plan held to the brief's own numbers (`DW0833`, `DW0834`).
2
3use super::*;
4
5// ---------------------------------------------------------------------------
6// Identities: the plan held to the brief's own numbers
7// ---------------------------------------------------------------------------
8
9/// What one measure came out at, or why it could not be taken.
10enum Measured {
11    Value(f64),
12    Unresolved(Diagnostic),
13}
14
15/// `DW0833` and `DW0834`: the brief's numbers still hold once the boxes are
16/// drawn, and the binding that holds them is not empty.
17///
18/// `DW0834` is a **warning**, not an error, and the difference is deliberate: a
19/// deliberately minimal plan — a fixture, a first sketch — is a legitimate
20/// thing to hold, and refusing it would make the smallest useful document
21/// uncompilable. What it may not be is *silent*, so the empty side is named on
22/// every run and is a finding for the round summary.
23///
24/// `DW0833` runs here over the plan. **Its second call site is the built
25/// world**, where the same rule recomputes the same measures from assembled
26/// bytes so that a derivation defect which moved a datum cannot hide behind a
27/// plan-time green. That site belongs to the round that builds the blockout;
28/// nothing here approximates it.
29pub(super) fn identities(
30    c: &Campaign,
31    plan: &SitePlanContent,
32    placed: &[Placed<'_>],
33    d: &mut Vec<Diagnostic>,
34) {
35    let facts: BTreeMap<&str, &crate::layout::BriefFact> = c
36        .geometry_brief
37        .as_ref()
38        .map(|b| {
39            b.content
40                .facts
41                .iter()
42                .map(|f| (f.id.0.as_str(), f))
43                .collect()
44        })
45        .unwrap_or_default();
46
47    if facts.is_empty() || plan.identities.is_empty() {
48        let empty = match (facts.is_empty(), plan.identities.is_empty()) {
49            (true, true) => "the brief states no fact and the plan declares no identity",
50            (true, false) => "the brief states no fact",
51            _ => "the plan declares no identity",
52        };
53        d.push(Diagnostic::warning(
54            DW_IDENTITY_EMPTY,
55            "site-plan",
56            "/content/identities",
57            format!(
58                "the identity gate binds nothing: {empty}. This is what holds the whole map to \
59                 the design somebody wrote down — with either side empty, the plan may say \
60                 anything at all and every check above will still pass, because none of them \
61                 has an opinion about how big the map was meant to be. It is a warning rather \
62                 than a refusal so that a deliberately minimal plan stays compilable; it is \
63                 printed every run so that the emptiness is never quietly a pass."
64            ),
65        ));
66    }
67
68    let by_node: BTreeMap<&str, &Placed<'_>> =
69        placed.iter().map(|p| (p.plan.node.0.as_str(), p)).collect();
70    let datums: BTreeMap<&str, i64> = plan.datums.iter().map(|x| (x.id.0.as_str(), x.y)).collect();
71
72    for (i, id) in plan.identities.iter().enumerate() {
73        let Some(fact) = facts.get(id.fact.0.as_str()) else {
74            d.push(Diagnostic::error(
75                DW_PLAN_AGREEMENT,
76                "site-plan",
77                format!("/content/identities/{i}/fact"),
78                format!(
79                    "this identity holds the map to `{f}`, which the geometry brief states no \
80                     fact for. An identity binds to a number the brief WROTE DOWN — that is what \
81                     makes it a design being kept rather than an assertion the plan makes about \
82                     itself.",
83                    f = id.fact,
84                ),
85            ));
86            continue;
87        };
88        let measured = measure(&id.measure, plan, &by_node, &datums, i);
89        let value = match measured {
90            Measured::Value(v) => v,
91            Measured::Unresolved(diag) => {
92                d.push(diag);
93                continue;
94            }
95        };
96        if id.cmp.holds(value, fact.value) {
97            continue;
98        }
99        d.push(Diagnostic::error(
100            DW_IDENTITY_FALSE,
101            "site-plan",
102            format!("/content/identities/{i}"),
103            format!(
104                "the plan does not keep `{f}`: {what} measures {value}, and the brief asks for \
105                 {cmp} {want}{unit}. The brief's sentence was: \"{note}\". Either move the \
106                 geometry until the number is true, or change the brief's fact — in the brief, \
107                 where the design is written down, so that the change is a decision somebody \
108                 took rather than a plan that drifted.",
109                f = id.fact,
110                what = describe(&id.measure),
111                cmp = id.cmp.as_str(),
112                want = fact.value,
113                unit = fact
114                    .unit
115                    .as_ref()
116                    .map(|u| format!(" {u}"))
117                    .unwrap_or_default(),
118                note = fact.note,
119            ),
120        ));
121    }
122}
123
124/// Take one measure off the plan.
125fn measure(
126    m: &Measure,
127    plan: &SitePlanContent,
128    by_node: &BTreeMap<&str, &Placed<'_>>,
129    datums: &BTreeMap<&str, i64>,
130    i: usize,
131) -> Measured {
132    let missing_node = |node: &NodeId| {
133        Measured::Unresolved(Diagnostic::error(
134            DW_PLAN_AGREEMENT,
135            "site-plan",
136            format!("/content/identities/{i}/measure"),
137            format!(
138                "this identity measures `{node}`, which this plan embeds no box for. A measure \
139                 is taken off the geometry, so it can only name a place the plan actually put \
140                 somewhere."
141            ),
142        ))
143    };
144    match m {
145        Measure::RegionExtent { axis } => {
146            Measured::Value(f64::from(plan.region.extent[axis.index()].get()))
147        }
148        Measure::BoxExtent { node, axis } => match by_node.get(node.0.as_str()) {
149            Some(p) => Measured::Value(f64::from(p.plan.extent[axis.index()].get())),
150            None => missing_node(node),
151        },
152        Measure::BoxHeight { node } => match by_node.get(node.0.as_str()) {
153            Some(p) => Measured::Value(f64::from(p.clearance)),
154            None => missing_node(node),
155        },
156        Measure::DistanceXz { from, to } => {
157            let (Some(a), Some(b)) = (by_node.get(from.0.as_str()), by_node.get(to.0.as_str()))
158            else {
159                return missing_node(if by_node.contains_key(from.0.as_str()) {
160                    to
161                } else {
162                    from
163                });
164            };
165            let (ax, az) = a.centre_xz();
166            let (bx, bz) = b.centre_xz();
167            Measured::Value(((bx - ax).powi(2) + (bz - az).powi(2)).sqrt())
168        }
169        Measure::DatumY { datum } => match datums.get(datum.0.as_str()) {
170            Some(y) => Measured::Value(*y as f64),
171            None => Measured::Unresolved(Diagnostic::error(
172                crate::codes::DANGLING_REF,
173                "site-plan",
174                format!("/content/identities/{i}/measure"),
175                format!(
176                    "this identity measures `{datum}`, which this plan declares no `datums[]` \
177                     entry for."
178                ),
179            )),
180        },
181    }
182}
183
184/// What a measure is, in a refusal's own words.
185fn describe(m: &Measure) -> String {
186    match m {
187        Measure::RegionExtent { axis } => {
188            format!("the region's extent on {}", axis.as_str())
189        }
190        Measure::BoxExtent { node, axis } => {
191            format!("`{node}`'s footprint on {}", axis.as_str())
192        }
193        Measure::BoxHeight { node } => format!("`{node}`'s headroom"),
194        Measure::DistanceXz { from, to } => {
195            format!("the horizontal distance from `{from}` to `{to}`")
196        }
197        Measure::DatumY { datum } => format!("the plane `{datum}`"),
198    }
199}
200
201impl Axis {
202    fn index(self) -> usize {
203        match self {
204            Axis::X => 0,
205            Axis::Y => 1,
206            Axis::Z => 2,
207        }
208    }
209
210    fn as_str(self) -> &'static str {
211        match self {
212            Axis::X => "x",
213            Axis::Y => "y",
214            Axis::Z => "z",
215        }
216    }
217}
218
219impl PlanAxis {
220    fn index(self) -> usize {
221        match self {
222            PlanAxis::X => 0,
223            PlanAxis::Z => 1,
224        }
225    }
226
227    fn as_str(self) -> &'static str {
228        match self {
229            PlanAxis::X => "x",
230            PlanAxis::Z => "z",
231        }
232    }
233}
234
235impl VolumeRole {
236    /// The keyword a refusal prints.
237    #[must_use]
238    pub fn as_str(self) -> &'static str {
239        match self {
240            VolumeRole::Massif => "massif",
241            VolumeRole::Ground => "ground",
242            VolumeRole::Clearance => "clearance",
243        }
244    }
245}