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}