Skip to main content

delvewright_dsl/
detailplan.rs

1//! **The detail plan: a place is detailed inside the box the whole gave it**
2//! (spec-0050) — pipeline stage 6.
3//!
4//! One campaign stage document, `detail-plan.json`, and its whole surface is
5//! *which piece stands in which place, and which of the piece's anchors answers
6//! each name the campaign already bound to that place*.
7//!
8//! # What the schema deliberately cannot say
9//!
10//! There is **no coordinate, no region, no extent, no datum, no seam and no
11//! offset** below — absent fields, not optional ones. A detail document is
12//! therefore *structurally unable* to move its box, its datum or its seams,
13//! because the schema has no spelling for any of them; the only path from a
14//! [`Detail`] row to placed bytes runs through the compiler computing the frame
15//! ([`Frame::of`]) from the site plan, inside `Plan::build`, which is the only
16//! constructor every world-reaching verb goes through.
17//!
18//! This is the same tooth the blockout's is (`crate::siteplan`, and
19//! `delvec::compiler::blockout`'s module docs): inversion is not forbidden,
20//! it is **uncompilable**. A part that wants different traversal takes the one
21//! escalation path there is — a site-plan revision, which moves the plan hash,
22//! which re-opens the walk gate, which re-runs the whole's walk.
23//!
24//! # Partial by construction
25//!
26//! Detail is per-place. The derivation masses every *unbound* box exactly as it
27//! did at stage 5, so a campaign with one detailed place builds, walks, renders
28//! and reds like any other — the broken intermediate is a real, lookable object
29//! at every point between "no detail" and "fully detailed".
30
31use std::collections::{BTreeMap, BTreeSet};
32
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35
36use crate::envelope::Campaign;
37use crate::ids::{NodeId, PrefabId};
38use crate::siteplan::PlacedBox;
39
40// ---------------------------------------------------------------------------
41// The document (spec-0050 §1)
42// ---------------------------------------------------------------------------
43
44/// The `detail-plan` stage document's payload.
45///
46/// Two fields, and the second is the whole mechanism. See the module docs for
47/// what is deliberately absent and why that absence is the design.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct DetailPlanContent {
51    /// **The whole's material vocabulary**: role name → block, handed into every
52    /// allocation (spec-0050 §4).
53    ///
54    /// Style surface, and **gated by nothing** — deliberately, and the reason is
55    /// a standing decision rather than an omission: materials are style, style
56    /// authority is rank-only (spec-0028), and a piece exported against a stale
57    /// palette is a render finding rather than a machine one. The provenance row
58    /// in the piece's own metadata already freezes what it was actually built
59    /// from.
60    ///
61    /// Absent means the whole states no vocabulary, which is a different claim
62    /// from an empty one — an empty map is the positive statement that the roles
63    /// are the piece's own business.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub palette: Option<BTreeMap<String, String>>,
66    /// One row per **detailed place**. A place with no row is massed by the
67    /// derivation exactly as it was at stage 5.
68    pub details: Vec<Detail>,
69}
70
71/// One place, detailed: the piece that stands in it and the anchor re-binding
72/// that keeps the campaign's own names working.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct Detail {
76    /// The layout-graph node whose box this piece fills.
77    ///
78    /// A node, not a box and not a region: the ordering tooth at type level, the
79    /// same one `PlanBox::node` is. There is no other way to say *where*.
80    pub place: NodeId,
81    /// The piece — a prefab: frozen bytes plus metadata carrying a resolved
82    /// spatial contract, faces and anchors.
83    ///
84    /// The engine consumes the object class, never the tool that made it
85    /// (spec-0050 §1): a grammar program's export and a hand-admitted kit piece
86    /// are the same object here, and every gate below reads metadata and bytes,
87    /// indifferent to provenance.
88    pub piece: PrefabId,
89    /// Each synthesized anchor name this place **owes** ([`owed_anchors`]) →
90    /// an anchor of the piece.
91    ///
92    /// The re-binding is what lets a kit piece keep its own vocabulary while the
93    /// campaign keeps its own: the quest layer bound `anchor/node-…` to this
94    /// place at stage 3, before any detail existed, so detailing must never
95    /// force a quest edit.
96    #[serde(default)]
97    pub anchors: BTreeMap<String, String>,
98}
99
100impl DetailPlanContent {
101    /// The row that binds `node`, if any.
102    #[must_use]
103    pub fn detail_of(&self, node: &NodeId) -> Option<&Detail> {
104        self.details.iter().find(|d| &d.place == node)
105    }
106}
107
108// ---------------------------------------------------------------------------
109// The frame (spec-0050 §3) — ONE derivation, four readers
110// ---------------------------------------------------------------------------
111
112/// **How many courses of floor a piece owns under its play space** — the whole
113/// of the fabric split (spec-0050 §3), as one number.
114///
115/// A floor's material is the place's own voice, so the course the walk plane
116/// stands on belongs to the piece; everything else the derivation writes around
117/// a box — walls, ceiling, seam frames, party planes — is structure, and
118/// structure is the whole's. Named rather than spelled `1` at each site so that
119/// [`Frame::of`], [`Frame::datum_y`] and every reader of either move together.
120pub const FLOOR_COURSE: i64 = 1;
121
122/// **What a piece owns**: the box's play space, grown [`FLOOR_COURSE`] downward
123/// to take in the floor the walk plane stands on.
124///
125/// One derivation, [`Frame::of`], and four readers that must not disagree: the
126/// exactness check (`DW0843`), the face check (`DW0844`), the placement inside
127/// `Plan::build`, and `delvec allocation`. Two of them computing "where does
128/// this piece go" independently is how a builder and its observer come to agree
129/// about a world neither describes — the failure `PlacedBox`'s own note records
130/// one layer down.
131///
132/// What is **not** in here is as load-bearing as what is: every vertical party
133/// plane, every unshared shell face, every derived stair in an unbound host and
134/// every bar in a vertical-plane seam stay whole-owned. The piece dresses its
135/// side of a wall from within its own frame; the party plane is structure, and
136/// structure is the whole's.
137///
138/// A seam's FRAME is whole-owned on the same terms and with one recorded
139/// exception: where two boxes stack, the horizontal party plane between them is
140/// the upper box's floor course, so a bound upper box owns the frame cut in it
141/// along with the rest of its floor. That is spec-0050 §3's own stacked-box
142/// sentence; the reasoning is at `blockout::Mass::holes`, where the choice is
143/// made.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Frame {
146    /// The place this frames.
147    pub node: NodeId,
148    /// Inclusive low corner in world cells — the floor course.
149    pub lo: [i64; 3],
150    /// Inclusive high corner in world cells — the top of the play space.
151    pub hi: [i64; 3],
152}
153
154impl Frame {
155    /// The frame a detailed place's piece must exactly fill.
156    ///
157    /// The play space plus the floor course under it, and nothing else. A
158    /// floor's material is the place's own voice — the datum convention already
159    /// says the walk plane is the plan's, so the handing states the datum in
160    /// piece-local coordinates and the seam-rise proofs hold it.
161    #[must_use]
162    pub fn of(b: &PlacedBox) -> Frame {
163        let (lo, hi) = b.space();
164        Frame {
165            node: b.node.clone(),
166            lo: [lo[0], lo[1] - FLOOR_COURSE, lo[2]],
167            hi,
168        }
169    }
170
171    /// The frame's size in cells, `[x, y, z]` — what a piece's structure size
172    /// must equal on every axis (`DW0843`).
173    #[must_use]
174    pub fn extent(&self) -> [i64; 3] {
175        [
176            self.hi[0] - self.lo[0] + 1,
177            self.hi[1] - self.lo[1] + 1,
178            self.hi[2] - self.lo[2] + 1,
179        ]
180    }
181
182    /// The walk plane's **piece-local** `y` — where the piece's own floor
183    /// surface must be.
184    ///
185    /// It is [`FLOOR_COURSE`] because the frame is the play space grown exactly
186    /// that many courses downward, so the number is a consequence of
187    /// [`Frame::of`] and moves with it rather than being a literal repeated at
188    /// four call sites.
189    #[must_use]
190    pub fn datum_y(&self) -> i64 {
191        FLOOR_COURSE
192    }
193
194    /// A world cell in this frame's local coordinates.
195    #[must_use]
196    pub fn to_local(&self, world: [i64; 3]) -> [i64; 3] {
197        [
198            world[0] - self.lo[0],
199            world[1] - self.lo[1],
200            world[2] - self.lo[2],
201        ]
202    }
203
204    /// True when `world` is inside the frame, inclusive.
205    #[must_use]
206    pub fn contains(&self, world: [i64; 3]) -> bool {
207        (0..3).all(|i| world[i] >= self.lo[i] && world[i] <= self.hi[i])
208    }
209}
210
211// ---------------------------------------------------------------------------
212// What a detail plan binds, for the derivation and for every gate
213// ---------------------------------------------------------------------------
214
215/// The layout-graph nodes a campaign's detail plan binds, by name.
216///
217/// Read by the blockout derivation, which stops massing what a binding owns —
218/// so the answer must come from the document rather than from a second opinion
219/// about which rows are "valid": a row naming a node the graph does not have is
220/// `DW0842`'s finding, and a derivation that quietly disagreed with the gate
221/// about which places are bound would be a world neither describes.
222///
223/// Empty for a campaign with no detail plan, which is every campaign that
224/// existed before this version — and is why such a campaign's output does not
225/// move by a byte.
226#[must_use]
227pub fn bound_places(c: &Campaign) -> BTreeSet<String> {
228    c.detail_plan
229        .as_ref()
230        .map(|e| {
231            e.content
232                .details
233                .iter()
234                .map(|d| d.place.0.clone())
235                .collect()
236        })
237        .unwrap_or_default()
238}
239
240/// True when `node`'s box is bound by a `details[]` row.
241#[must_use]
242pub fn is_bound(c: &Campaign, node: &NodeId) -> bool {
243    c.detail_plan
244        .as_ref()
245        .is_some_and(|e| e.content.detail_of(node).is_some())
246}
247
248/// The synthesized anchor names **this place owes** (spec-0050 §6).
249///
250/// Exactly the synthesized names whose bearer is this box: its own
251/// `anchor/node-…`; `spawn` when it is the entry node; each `anchor/unlock-…`
252/// whose `opens_from` side it is. A gate region (`anchor/seam-…`) is never owed
253/// — it is whole fabric, and one over a bound upper box's floor course resolves
254/// to the seam's allocated cells as ever, which `DW0844`'s barred row has
255/// already required the piece to bar.
256///
257/// Derived from the same two documents [`crate::siteplan::synthesized_anchors`]
258/// reads, and **proven to partition it**: a test walks every node, unions the
259/// owed sets with the seam anchors, and asserts equality with the one authority.
260/// A name that stopped being owed by anyone would otherwise be a name the piece
261/// is never asked for and the campaign still resolves.
262#[must_use]
263pub fn owed_anchors(c: &Campaign, node: &NodeId) -> BTreeSet<String> {
264    crate::siteplan::owed_anchors(c, node)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::ids::NodeId;
271    use crate::siteplan::PlacedBox;
272
273    fn a_box() -> PlacedBox {
274        PlacedBox {
275            node: NodeId("node/hall".into()),
276            foot: [10, 25, 4, 19],
277            floor: 64,
278            clearance: 8,
279            open: false,
280        }
281    }
282
283    #[test]
284    fn a_frame_is_the_play_space_plus_one_course_below() {
285        let b = a_box();
286        let (lo, hi) = b.space();
287        let f = Frame::of(&b);
288        assert_eq!(
289            f.lo,
290            [lo[0], lo[1] - 1, lo[2]],
291            "one course below the walk plane"
292        );
293        assert_eq!(f.hi, hi, "and nothing above the play space");
294        assert_eq!(
295            f.extent(),
296            [16, 9, 16],
297            "footprint by clearance + the floor course"
298        );
299        assert_eq!(
300            f.datum_y(),
301            1,
302            "the walk plane sits one course up, piece-local"
303        );
304        assert_eq!(f.to_local([lo[0], b.floor, lo[2]]), [0, 1, 0]);
305        assert!(
306            f.contains([lo[0], b.floor - 1, lo[2]]),
307            "the floor course is the piece's"
308        );
309        assert!(
310            !f.contains([lo[0], b.floor - 2, lo[2]]),
311            "and nothing under it is"
312        );
313    }
314
315    /// The schema's absence is the design, so it is asserted rather than
316    /// described: a document naming a coordinate does not parse.
317    #[test]
318    fn a_detail_row_cannot_state_where_anything_goes() {
319        for extra in [
320            r#""min": [0, 0, 0]"#,
321            r#""at": [1, 2]"#,
322            r#""region": {"min": [0, 0, 0], "extent": [1, 1, 1]}"#,
323            r#""datum": "datum/grade""#,
324            r#""offset": [0, 1, 0]"#,
325            r#""extent": [4, 4, 4]"#,
326            r#""seams": []"#,
327        ] {
328            let src = format!(
329                r#"{{"place": "node/hall", "piece": "prefab/hall", "anchors": {{}}, {extra}}}"#
330            );
331            let err = serde_json::from_str::<Detail>(&src)
332                .expect_err("a detail row has no spelling for where anything goes");
333            assert!(
334                err.to_string().contains("unknown field"),
335                "the refusal is the schema's, not a check's: {err}"
336            );
337        }
338    }
339
340    #[test]
341    fn a_detail_plan_cannot_state_where_anything_goes() {
342        for extra in [
343            r#""region": {"min": [0, 0, 0], "extent": [1, 1, 1]}"#,
344            r#""datums": []"#,
345            r#""boxes": []"#,
346            r#""seams": []"#,
347            r#""origin": [0, 0, 0]"#,
348        ] {
349            let src = format!(r#"{{"details": [], {extra}}}"#);
350            let err = serde_json::from_str::<DetailPlanContent>(&src)
351                .expect_err("a detail plan has no spelling for geometry");
352            assert!(
353                err.to_string().contains("unknown field"),
354                "the refusal is the schema's, not a check's: {err}"
355            );
356        }
357    }
358
359    #[test]
360    fn the_document_round_trips_and_defaults_to_nothing_bound() {
361        let d: DetailPlanContent = serde_json::from_str(r#"{"details": []}"#).unwrap();
362        assert!(d.palette.is_none(), "absent is not empty");
363        assert!(d.details.is_empty());
364        let d: DetailPlanContent = serde_json::from_str(
365            r#"{"palette": {"role/wall": "minecraft:stone_bricks"},
366                "details": [{"place": "node/hall", "piece": "prefab/hall"}]}"#,
367        )
368        .unwrap();
369        assert_eq!(d.details[0].anchors.len(), 0, "`anchors` defaults to empty");
370        assert!(d.detail_of(&NodeId("node/hall".into())).is_some());
371        assert!(d.detail_of(&NodeId("node/annex".into())).is_none());
372    }
373}