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};
38#[cfg(doc)]
39use crate::owed_anchors;
40use crate::siteplan::{PlacedBox, Site, SitePlan};
41
42// ---------------------------------------------------------------------------
43// The document (spec-0050 §1)
44// ---------------------------------------------------------------------------
45
46/// The `detail-plan` stage document's payload.
47///
48/// Two fields, and the second is the whole mechanism. See the module docs for
49/// what is deliberately absent and why that absence is the design.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct DetailPlanContent {
53    /// **The whole's material vocabulary**: role name → block, handed into every
54    /// allocation (spec-0050 §4).
55    ///
56    /// Style surface, and **gated by nothing** — deliberately, and the reason is
57    /// a standing decision rather than an omission: materials are style, style
58    /// authority is rank-only (spec-0028), and a piece exported against a stale
59    /// palette is a render finding rather than a machine one. The provenance row
60    /// in the piece's own metadata already freezes what it was actually built
61    /// from.
62    ///
63    /// Absent means the whole states no vocabulary, which is a different claim
64    /// from an empty one — an empty map is the positive statement that the roles
65    /// are the piece's own business.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub palette: Option<BTreeMap<String, String>>,
68    /// One row per **detailed place**. A place with no row is massed by the
69    /// derivation exactly as it was at stage 5.
70    pub details: Vec<Detail>,
71}
72
73/// One place, detailed: the piece that stands in it and the anchor re-binding
74/// that keeps the campaign's own names working.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Detail {
78    /// The layout-graph node whose box this piece fills.
79    ///
80    /// A node, not a box and not a region: the ordering tooth at type level, the
81    /// same one `PlanBox::node` is. There is no other way to say *where*.
82    pub place: NodeId,
83    /// The piece — a prefab: frozen bytes plus metadata carrying a resolved
84    /// spatial contract, faces and anchors.
85    ///
86    /// The engine consumes the object class, never the tool that made it
87    /// (spec-0050 §1): a grammar program's export and a hand-admitted kit piece
88    /// are the same object here, and every gate below reads metadata and bytes,
89    /// indifferent to provenance.
90    pub piece: PrefabId,
91    /// Each synthesized anchor name this place **owes** ([`owed_anchors`]) →
92    /// an anchor of the piece.
93    ///
94    /// The re-binding is what lets a kit piece keep its own vocabulary while the
95    /// campaign keeps its own: the quest layer bound `anchor/node-…` to this
96    /// place at stage 3, before any detail existed, so detailing must never
97    /// force a quest edit.
98    #[serde(default)]
99    pub anchors: BTreeMap<String, String>,
100}
101
102impl DetailPlanContent {
103    /// The row that binds `node`, if any.
104    #[must_use]
105    pub fn detail_of(&self, node: &NodeId) -> Option<&Detail> {
106        self.details.iter().find(|d| &d.place == node)
107    }
108}
109
110// ---------------------------------------------------------------------------
111// The frame (spec-0050 §3) — ONE derivation, four readers
112// ---------------------------------------------------------------------------
113
114/// **What a piece owns**: the place's claim — the ground under its plot, its
115/// floor course, its play space, the one-cell ring its walls may stand in and,
116/// roofed, its lid and the roof zone the plan declares — minus the ring's fixed
117/// ground and every cell spec-0098 §2's ownership rule awards to another place
118/// or to nobody (`siteplan::Site::owner`).
119///
120/// One derivation, [`Frame::of`], and every reader that must not disagree: the
121/// exactness check (`DW0843`), the face check (`DW0844`), the void and ring
122/// checks (`DW0987`, `DW0990`), the blockout's stand-ins and holes, the
123/// placement inside `Plan::build`, and `delvec allocation`. Two of them
124/// computing "whose cell is this" independently is how a builder and its
125/// observer come to agree about a world neither describes.
126///
127/// The frame is the **bounding box** of the owned cells, because a piece is a
128/// structure template and a template is a box. Cells inside it the place does
129/// not own are its [`Frame::voids`]: the piece holds `structure_void` there and
130/// the owner's block shows through, in the game and in the model alike.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Frame {
133    /// The place this frames.
134    pub node: NodeId,
135    /// Inclusive low corner in world cells.
136    pub lo: [i64; 3],
137    /// Inclusive high corner in world cells.
138    pub hi: [i64; 3],
139    /// The walk plane's world `y`.
140    pub floor: i64,
141    /// The claim's bottom: the lowest course of ground under the plot.
142    pub bottom: i64,
143    /// The cells the place owns, merged into disjoint AABBs.
144    pub owned: Vec<crate::siteplan::Aabb>,
145    /// How many cells the place owns.
146    pub owned_cells: usize,
147    /// Every cell of the frame the place does not own, with who does.
148    pub voids: Vec<crate::siteplan::Void>,
149    /// The ring's fixed ground inside the frame, each cell with the block the
150    /// whole writes there (spec-0098 §2 rule 0).
151    pub fixed: Vec<([i64; 3], String)>,
152    /// Eaves cells the plan clipped at a neighbour's shell.
153    pub clipped: Vec<(crate::siteplan::Aabb, NodeId)>,
154}
155
156impl Frame {
157    /// The frame of the plan's `i`th place, derived from the whole plan:
158    /// who owns a party plane depends on what stands on its other side, and the
159    /// ring's ground on the site's terrain.
160    #[must_use]
161    pub fn of(site: &Site<'_>, i: usize) -> Frame {
162        let b = &site.boxes[i];
163        let o = site.ownership(i);
164        Frame {
165            node: b.node.clone(),
166            lo: o.lo,
167            hi: o.hi,
168            floor: b.floor,
169            bottom: site.bottom(i),
170            owned: o.owned,
171            owned_cells: o.owned_cells,
172            voids: o.voids,
173            fixed: o.fixed,
174            clipped: o.clipped,
175        }
176    }
177
178    /// The frame's size in cells, `[x, y, z]` — what a piece's structure size
179    /// must equal on every axis (`DW0843`).
180    #[must_use]
181    pub fn extent(&self) -> [i64; 3] {
182        [
183            self.hi[0] - self.lo[0] + 1,
184            self.hi[1] - self.lo[1] + 1,
185            self.hi[2] - self.lo[2] + 1,
186        ]
187    }
188
189    /// The walk plane's **piece-local** `y` — where the piece's own floor
190    /// surface must be: one over the floor course, plus every course of ground
191    /// the claim reaches below it (spec-0098 §2).
192    #[must_use]
193    pub fn datum_y(&self) -> i64 {
194        self.floor - self.lo[1]
195    }
196
197    /// A world cell in this frame's local coordinates.
198    #[must_use]
199    pub fn to_local(&self, world: [i64; 3]) -> [i64; 3] {
200        [
201            world[0] - self.lo[0],
202            world[1] - self.lo[1],
203            world[2] - self.lo[2],
204        ]
205    }
206
207    /// True when `world` is inside the frame, inclusive.
208    #[must_use]
209    pub fn contains(&self, world: [i64; 3]) -> bool {
210        (0..3).all(|i| world[i] >= self.lo[i] && world[i] <= self.hi[i])
211    }
212
213    /// True when `world` is a cell the place owns — inside the frame and in no
214    /// void.
215    #[must_use]
216    pub fn owns(&self, world: [i64; 3]) -> bool {
217        self.owned
218            .iter()
219            .any(|(lo, hi)| (0..3).all(|i| world[i] >= lo[i] && world[i] <= hi[i]))
220    }
221
222    /// Every frame of a campaign's plan, in plan document order.
223    #[must_use]
224    pub fn all(c: &Campaign) -> Vec<(Frame, PlacedBox)> {
225        let plan = SitePlan::of(c);
226        let site = plan.site();
227        (0..plan.boxes.len())
228            .map(|i| (Frame::of(&site, i), plan.boxes[i].clone()))
229            .collect()
230    }
231
232    /// True when `world` is fixed ground inside this frame.
233    #[must_use]
234    pub fn is_fixed(&self, world: [i64; 3]) -> bool {
235        self.fixed.iter().any(|(c, _)| *c == world)
236    }
237}
238
239// ---------------------------------------------------------------------------
240// What a detail plan binds, for the derivation and for every gate
241// ---------------------------------------------------------------------------
242
243/// The layout-graph nodes a campaign's detail plan binds, by name.
244///
245/// Read by the blockout derivation, which stops massing what a binding owns —
246/// so the answer must come from the document rather than from a second opinion
247/// about which rows are "valid": a row naming a node the graph does not have is
248/// `DW0842`'s finding, and a derivation that quietly disagreed with the gate
249/// about which places are bound would be a world neither describes.
250///
251/// Empty for a campaign with no detail plan, which is every campaign that
252/// existed before this version — and is why such a campaign's output does not
253/// move by a byte.
254#[must_use]
255pub fn bound_places(c: &Campaign) -> BTreeSet<String> {
256    c.detail_plan
257        .as_ref()
258        .map(|e| {
259            e.content
260                .details
261                .iter()
262                .map(|d| d.place.0.clone())
263                .collect()
264        })
265        .unwrap_or_default()
266}
267
268/// True when `node`'s box is bound by a `details[]` row.
269#[must_use]
270pub fn is_bound(c: &Campaign, node: &NodeId) -> bool {
271    c.detail_plan
272        .as_ref()
273        .is_some_and(|e| e.content.detail_of(node).is_some())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::ids::NodeId;
280    use crate::siteplan::{Ground, Owner, PlacedBox, Roof, Site};
281
282    fn a_box() -> PlacedBox {
283        PlacedBox {
284            node: NodeId("node/hall".into()),
285            foot: [10, 25, 4, 19],
286            floor: 64,
287            clearance: 8,
288            open: false,
289            base: crate::siteplan::Base::Ground,
290            roof: None,
291        }
292    }
293
294    fn frame(b: &PlacedBox, ground: &Ground) -> Frame {
295        let boxes = vec![b.clone()];
296        let site = Site::new(&boxes, &[], ground);
297        Frame::of(&site, 0)
298    }
299
300    /// Criterion 2: the expected extents are literals worked out here from the
301    /// box's own numbers, never the frame compared with itself.
302    #[test]
303    fn a_frame_is_the_shell_when_nothing_stands_beside_it() {
304        let b = a_box();
305        let f = frame(&b, &Ground::solid("minecraft:stone"));
306        // Footprint x 10..25, z 4..19; play y 64..71; lid 72; floor course 63.
307        assert_eq!(f.lo, [9, 63, 3], "the floor course and the ring");
308        assert_eq!(f.hi, [26, 72, 20], "the ring and the lid");
309        assert_eq!(f.extent(), [18, 10, 18]);
310        assert_eq!(f.datum_y(), 1, "the walk plane sits one course up");
311        assert!(f.owns([9, 64, 4]), "the wall is the piece's");
312        assert!(!f.owns([9, 63, 4]), "the ring's ground is fixed");
313        assert!(f.is_fixed([9, 63, 4]));
314        assert!(!f.contains([8, 64, 4]), "and nothing beyond it is");
315        // 18 × 10 × 18 minus the ring's 68 ground cells.
316        assert_eq!(f.owned_cells, 18 * 10 * 18 - 68);
317        assert_eq!(f.fixed.len(), 68);
318        assert!(f.voids.iter().all(|v| v.owner == Owner::Ground));
319    }
320
321    #[test]
322    fn an_open_frame_has_no_lid_and_a_roofed_one_rises_by_its_roof() {
323        let mut open = a_box();
324        open.open = true;
325        open.clearance = 3;
326        let f = frame(&open, &Ground::solid("minecraft:stone"));
327        assert_eq!(f.extent(), [18, 4, 18], "floor course + 3, no lid");
328        let mut roofed = a_box();
329        roofed.roof = Some(Roof {
330            courses: 4,
331            eaves: 1,
332        });
333        let f = frame(&roofed, &Ground::solid("minecraft:stone"));
334        assert_eq!(
335            f.extent(),
336            [20, 14, 20],
337            "eaves 1 each side; floor + 8 + lid + 4"
338        );
339        assert_eq!(f.lo, [8, 63, 2]);
340        assert_eq!(f.hi, [27, 76, 21]);
341        assert_eq!(f.datum_y(), 1);
342        // The columns beyond the walls under the eaves are nobody's: voids.
343        assert!(f.voids.iter().any(|v| v.owner == Owner::Nobody));
344    }
345
346    /// On an open site whose terrain stands lower than the floor, the claim
347    /// reaches down to the terrain's surface, so the frame grows downward.
348    #[test]
349    fn a_plinth_over_low_ground_claims_down_to_the_ground() {
350        let b = a_box();
351        let f = frame(
352            &b,
353            &Ground::open_flat(60, "minecraft:grass_block", "minecraft:dirt"),
354        );
355        assert_eq!(f.bottom, 60);
356        assert_eq!(f.lo[1], 60);
357        assert_eq!(f.datum_y(), 4);
358        assert!(f.is_fixed([9, 60, 4]), "the ring's surface cell is fixed");
359        assert!(
360            f.owns([9, 61, 4]),
361            "above the ground the ring is the piece's"
362        );
363        assert!(
364            f.owns([12, 61, 6]),
365            "the ground under the plot is the piece's"
366        );
367        let fixed: Vec<&String> = f.fixed.iter().map(|(_, b)| b).collect();
368        assert!(fixed.iter().all(|b| b.as_str() == "minecraft:grass_block"));
369    }
370
371    /// The schema's absence is the design, so it is asserted rather than
372    /// described: a document naming a coordinate does not parse.
373    #[test]
374    fn a_detail_row_cannot_state_where_anything_goes() {
375        for extra in [
376            r#""min": [0, 0, 0]"#,
377            r#""at": [1, 2]"#,
378            r#""region": {"min": [0, 0, 0], "extent": [1, 1, 1]}"#,
379            r#""datum": "datum/grade""#,
380            r#""offset": [0, 1, 0]"#,
381            r#""extent": [4, 4, 4]"#,
382            r#""seams": []"#,
383        ] {
384            let src = format!(
385                r#"{{"place": "node/hall", "piece": "prefab/hall", "anchors": {{}}, {extra}}}"#
386            );
387            let err = serde_json::from_str::<Detail>(&src)
388                .expect_err("a detail row has no spelling for where anything goes");
389            assert!(
390                err.to_string().contains("unknown field"),
391                "the refusal is the schema's, not a check's: {err}"
392            );
393        }
394    }
395
396    #[test]
397    fn a_detail_plan_cannot_state_where_anything_goes() {
398        for extra in [
399            r#""region": {"min": [0, 0, 0], "extent": [1, 1, 1]}"#,
400            r#""datums": []"#,
401            r#""boxes": []"#,
402            r#""seams": []"#,
403            r#""origin": [0, 0, 0]"#,
404        ] {
405            let src = format!(r#"{{"details": [], {extra}}}"#);
406            let err = serde_json::from_str::<DetailPlanContent>(&src)
407                .expect_err("a detail plan has no spelling for geometry");
408            assert!(
409                err.to_string().contains("unknown field"),
410                "the refusal is the schema's, not a check's: {err}"
411            );
412        }
413    }
414
415    #[test]
416    fn the_document_round_trips_and_defaults_to_nothing_bound() {
417        let d: DetailPlanContent = serde_json::from_str(r#"{"details": []}"#).unwrap();
418        assert!(d.palette.is_none(), "absent is not empty");
419        assert!(d.details.is_empty());
420        let d: DetailPlanContent = serde_json::from_str(
421            r#"{"palette": {"role/wall": "minecraft:stone_bricks"},
422                "details": [{"place": "node/hall", "piece": "prefab/hall"}]}"#,
423        )
424        .unwrap();
425        assert_eq!(d.details[0].anchors.len(), 0, "`anchors` defaults to empty");
426        assert!(d.detail_of(&NodeId("node/hall".into())).is_some());
427        assert!(d.detail_of(&NodeId("node/annex".into())).is_none());
428    }
429}