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}