Skip to main content

delvewright_dsl/siteplan/
claim.rs

1//! **A place's claim, and who owns a cell** (spec-0098 §2).
2//!
3//! A place owns everything a body can see of it from outside — the ground
4//! under its plot, its floor course, its play space, the one-cell ring its
5//! walls may stand in and, roofed, its lid and the roof zone the plan declares —
6//! minus the ring's **fixed ground**, which is the whole's terrain continued to
7//! the plot's edge, and minus every cell the ownership rule awards to a
8//! neighbour. This module is the one derivation of both halves: [`Site::claims`]
9//! says what a place would write if nothing stood beside it, [`Site::owner`]
10//! says who writes a cell, and [`crate::detailplan::Frame::of`] is built on
11//! nothing else.
12//!
13//! Every reader — the blockout's stand-ins and holes, the handout,
14//! `DW0827`/`DW0843`/`DW0844`/`DW0987`/`DW0990` — calls [`Site::owner`] or a
15//! function of it. Two of them deciding "whose cell is this" independently is
16//! how a builder and its observer come to agree about a world neither
17//! describes.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use crate::envelope::Campaign;
22use crate::ids::NodeId;
23
24use super::{Ground, PlacedBox, PlacedSeam};
25
26/// An inclusive world AABB, `(lo, hi)`.
27pub type Aabb = ([i64; 3], [i64; 3]);
28
29/// One group of `DW0827`: the places contesting, and the cells no rule awards them.
30pub type Contest = (Vec<NodeId>, Vec<[i64; 3]>);
31
32/// Who writes a cell (spec-0098 §2, rules 0–4).
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Owner {
35    /// The named place — its piece once bound, its stand-in while not.
36    Place(NodeId),
37    /// The ring's fixed ground (rule 0): the whole's terrain, written by no
38    /// piece.
39    Ground,
40    /// Nobody (rule 4): what a volume declares there, else the site's fill.
41    Nobody,
42    /// Two or more places claim the cell and no rule awards it (rule 3d) — a
43    /// plan `DW0827` refuses. Named so that a refused plan still answers.
44    Contested(Vec<NodeId>),
45}
46
47impl Owner {
48    /// The spelling the handout and the refusals use.
49    #[must_use]
50    pub fn describe(&self) -> String {
51        match self {
52            Owner::Place(n) => format!("`{n}`"),
53            Owner::Ground => "the whole's fixed ground".to_string(),
54            Owner::Nobody => "nobody (the site's fill)".to_string(),
55            Owner::Contested(ns) => format!(
56                "no one — contested by {}",
57                ns.iter()
58                    .map(|n| format!("`{n}`"))
59                    .collect::<Vec<_>>()
60                    .join(" and ")
61            ),
62        }
63    }
64}
65
66fn inside(cell: [i64; 3], (lo, hi): Aabb) -> bool {
67    (0..3).all(|i| cell[i] >= lo[i] && cell[i] <= hi[i])
68}
69
70fn intersect(a: Aabb, b: Aabb) -> Option<Aabb> {
71    let lo = [a.0[0].max(b.0[0]), a.0[1].max(b.0[1]), a.0[2].max(b.0[2])];
72    let hi = [a.1[0].min(b.1[0]), a.1[1].min(b.1[1]), a.1[2].min(b.1[2])];
73    (0..3).all(|i| lo[i] <= hi[i]).then_some((lo, hi))
74}
75
76fn cells(a: Aabb) -> impl Iterator<Item = [i64; 3]> {
77    let (lo, hi) = a;
78    (lo[0]..=hi[0]).flat_map(move |x| {
79        (lo[1]..=hi[1]).flat_map(move |y| (lo[2]..=hi[2]).map(move |z| [x, y, z]))
80    })
81}
82
83impl PlacedBox {
84    /// The top course of the play space.
85    #[must_use]
86    pub fn top(&self) -> i64 {
87        self.floor + i64::from(self.clearance) - 1
88    }
89
90    /// The highest course of the shell: the lid on a roofed place, the top of
91    /// the play space on an open one.
92    #[must_use]
93    pub fn shell_top(&self) -> i64 {
94        if self.open {
95            self.top()
96        } else {
97            self.top() + 1
98        }
99    }
100
101    /// The roof zone the plan declares, before any neighbour clips its eaves:
102    /// the shell footprint grown by `eaves` on every horizontal side, from the
103    /// ceiling course up `courses` courses. `None` when no roof is declared or
104    /// the place is open.
105    #[must_use]
106    pub fn roof_zone(&self) -> Option<Aabb> {
107        let roof = self.roof?;
108        if self.open {
109            return None;
110        }
111        let e = i64::from(roof.eaves);
112        let lid = self.top() + 1;
113        Some((
114            [self.foot[0] - 1 - e, lid, self.foot[2] - 1 - e],
115            [
116                self.foot[1] + 1 + e,
117                lid + i64::from(roof.courses),
118                self.foot[3] + 1 + e,
119            ],
120        ))
121    }
122
123    /// The highest course this place could claim.
124    #[must_use]
125    pub fn claim_top(&self) -> i64 {
126        self.roof_zone().map_or(self.shell_top(), |(_, hi)| hi[1])
127    }
128
129    /// The floor course's world `y`.
130    #[must_use]
131    pub fn floor_course_y(&self) -> i64 {
132        self.floor - 1
133    }
134
135    /// True when `(x, z)` is one of the ring's columns: inside the shell
136    /// footprint and outside the play space's.
137    #[must_use]
138    pub fn is_ring_column(&self, x: i64, z: i64) -> bool {
139        let [x0, x1, z0, z1] = self.foot;
140        let in_shell = x >= x0 - 1 && x <= x1 + 1 && z >= z0 - 1 && z <= z1 + 1;
141        let in_foot = x >= x0 && x <= x1 && z >= z0 && z <= z1;
142        in_shell && !in_foot
143    }
144}
145
146/// The whole plan, resolved for ownership: every place, every seam, the site's
147/// ground, and each place's claim bottom, column by column.
148#[derive(Debug, Clone)]
149pub struct Site<'a> {
150    /// The plan's places.
151    pub boxes: &'a [PlacedBox],
152    /// The plan's connections.
153    pub seams: &'a [PlacedSeam],
154    /// The site's fill.
155    pub ground: &'a Ground,
156    /// Each place's lowest claim bottom over its shell footprint.
157    bottoms: Vec<i64>,
158    /// Each place's claim bottom per shell column, `x`-major over its shell
159    /// footprint (the play space's footprint grown by the ring).
160    columns: Vec<Vec<i64>>,
161}
162
163impl PlacedBox {
164    /// The underside courses under the floor course, on an aloft place.
165    #[must_use]
166    pub fn aloft(&self) -> Option<u32> {
167        self.base.aloft()
168    }
169
170    /// True when `(x, z)` is inside the shell footprint: the play space's
171    /// footprint grown by the one-cell ring.
172    #[must_use]
173    pub fn in_shell_footprint(&self, x: i64, z: i64) -> bool {
174        let [x0, x1, z0, z1] = self.foot;
175        x >= x0 - 1 && x <= x1 + 1 && z >= z0 - 1 && z <= z1 + 1
176    }
177}
178
179impl<'a> Site<'a> {
180    /// Resolve the claim bottoms of `boxes` over `ground` (spec-0098 §2).
181    ///
182    /// **A ground place** reaches down, column by column, to the lower of its
183    /// floor course and the terrain under that column, so the ring's fixed
184    /// ground is in it — but never into a place stacked under it: in a column
185    /// another place's claim covers wholly below this one's floor, the bottom
186    /// stops one course above that claim's top, so the plane between two
187    /// stacked places is the upper's floor course (rule 3a) and not a column of
188    /// ground through the lower. Per column: a hall over a cellar owns its
189    /// ground beside the cellar, and only the columns over it stop short.
190    ///
191    /// **An aloft place** reaches down exactly its declared underside courses
192    /// under its floor course, everywhere, and never consults the terrain.
193    #[must_use]
194    pub fn new(boxes: &'a [PlacedBox], seams: &'a [PlacedSeam], ground: &'a Ground) -> Site<'a> {
195        let columns: Vec<Vec<i64>> = boxes
196            .iter()
197            .map(|b| {
198                let [x0, x1, z0, z1] = b.foot;
199                let course = b.floor_course_y();
200                let mut out = Vec::new();
201                for x in x0 - 1..=x1 + 1 {
202                    for z in z0 - 1..=z1 + 1 {
203                        if let Some(n) = b.aloft() {
204                            out.push(course - i64::from(n));
205                            continue;
206                        }
207                        let mut bottom = course;
208                        if ground.is_open()
209                            && let Some(t) = ground.top(x, z)
210                        {
211                            bottom = bottom.min(t);
212                        }
213                        for q in boxes {
214                            if q.node != b.node
215                                && q.in_shell_footprint(x, z)
216                                && q.claim_top() < b.floor
217                            {
218                                bottom = bottom.max(q.claim_top() + 1);
219                            }
220                        }
221                        out.push(bottom.min(course));
222                    }
223                }
224                out
225            })
226            .collect();
227        let bottoms = columns
228            .iter()
229            .map(|c| c.iter().copied().min().unwrap_or(i64::MAX))
230            .collect();
231        Site {
232            boxes,
233            seams,
234            ground,
235            bottoms,
236            columns,
237        }
238    }
239
240    /// The index of `node`'s box.
241    #[must_use]
242    pub fn index_of(&self, node: &NodeId) -> Option<usize> {
243        self.boxes.iter().position(|b| &b.node == node)
244    }
245
246    /// Place `i`'s lowest claim bottom — the bottom of its shell.
247    #[must_use]
248    pub fn bottom(&self, i: usize) -> i64 {
249        self.bottoms[i]
250    }
251
252    /// Place `i`'s claim bottom in shell column `(x, z)`; `None` outside its
253    /// shell footprint.
254    #[must_use]
255    pub fn column_bottom(&self, i: usize, x: i64, z: i64) -> Option<i64> {
256        let b = &self.boxes[i];
257        if !b.in_shell_footprint(x, z) {
258            return None;
259        }
260        let depth = b.foot[3] - b.foot[2] + 3;
261        let k = (x - (b.foot[0] - 1)) * depth + (z - (b.foot[2] - 1));
262        usize::try_from(k).ok().map(|k| self.columns[i][k])
263    }
264
265    /// True when place `i` is aloft.
266    #[must_use]
267    pub fn is_aloft(&self, i: usize) -> bool {
268        self.boxes[i].aloft().is_some()
269    }
270
271    /// Place `i`'s shell, as one box: the ground under its plot from its
272    /// lowest claim bottom, its floor course, its play space and its ring, and
273    /// on a roofed place its lid. A column's own bottom may stand higher
274    /// ([`Site::column_bottom`]).
275    #[must_use]
276    pub fn shell(&self, i: usize) -> Aabb {
277        let b = &self.boxes[i];
278        (
279            [b.foot[0] - 1, self.bottoms[i], b.foot[2] - 1],
280            [b.foot[1] + 1, b.shell_top(), b.foot[3] + 1],
281        )
282    }
283
284    /// True when `cell` is in place `i`'s shell: inside its bounding box and
285    /// at or above its column's claim bottom.
286    fn in_shell(&self, i: usize, cell: [i64; 3]) -> bool {
287        inside(cell, self.shell(i))
288            && self
289                .column_bottom(i, cell[0], cell[2])
290                .is_some_and(|b| cell[1] >= b)
291    }
292
293    /// Everything place `i` might claim, as one box — what a reader iterates.
294    #[must_use]
295    pub fn claim_bounds(&self, i: usize) -> Aabb {
296        let (mut lo, mut hi) = self.shell(i);
297        if let Some((zlo, zhi)) = self.boxes[i].roof_zone() {
298            for a in 0..3 {
299                lo[a] = lo[a].min(zlo[a]);
300                hi[a] = hi[a].max(zhi[a]);
301            }
302        }
303        (lo, hi)
304    }
305
306    /// **An aloft place standing in the earth** (`DW0990`, third shape): every
307    /// column under place `i`'s footprint or ring whose terrain surface reaches
308    /// its claim bottom, as `[x, z, surface y]` in column order. Empty for a
309    /// ground place and on a `solid` site, whose rock is carved by every claim.
310    #[must_use]
311    pub fn aloft_in_earth(&self, i: usize) -> Vec<[i64; 3]> {
312        let b = &self.boxes[i];
313        if b.aloft().is_none() || !self.ground.is_open() {
314            return Vec::new();
315        }
316        let bottom = self.bottoms[i];
317        let [x0, x1, z0, z1] = b.foot;
318        let mut out = Vec::new();
319        for x in x0 - 1..=x1 + 1 {
320            for z in z0 - 1..=z1 + 1 {
321                if let Some(t) = self.ground.top(x, z)
322                    && t >= bottom
323                {
324                    out.push([x, z, t]);
325                }
326            }
327        }
328        out
329    }
330
331    /// The ground height `G` of place `i`'s ring column `(x, z)` (spec-0098
332    /// §2 rule 0): the terrain's ground — on an `open` site the terrain's
333    /// surface `y`, on a `solid` site the floor course — except where a
334    /// vertical seam of this place crosses the column at grade: a seam whose
335    /// sill a body steps onto from that ground (sill − 1 at most one course
336    /// above it, or below it) levels the column to the sill minus one, flat
337    /// across the opening's width. A seam aloft — a bridge's deck, a door
338    /// high in a wall — fixes no earth under it: the column between the
339    /// terrain and the sill is the owner's (§14, a correction).
340    #[must_use]
341    pub fn ground_height(&self, i: usize, x: i64, z: i64) -> i64 {
342        let b = &self.boxes[i];
343        let terrain = self.terrain_height(i, x, z);
344        for s in self.seams {
345            if s.normal_axis == 1 || (s.a != b.node && s.b != b.node) {
346                continue;
347            }
348            let along = if s.normal_axis == 0 { z } else { x };
349            let across = if s.normal_axis == 0 { x } else { z };
350            let other = if s.normal_axis == 0 { 2 } else { 0 };
351            if across == s.plane
352                && along >= s.opening.0[other]
353                && along <= s.opening.1[other]
354                && s.opening.0[1] - 1 <= terrain + 1
355            {
356                return s.opening.0[1] - 1;
357            }
358        }
359        terrain
360    }
361
362    /// The terrain's ground under place `i`'s ring column `(x, z)`: the
363    /// surface `y` on an `open` site, the floor course on a `solid` one.
364    #[must_use]
365    pub fn terrain_height(&self, i: usize, x: i64, z: i64) -> i64 {
366        if self.ground.is_open()
367            && let Some(t) = self.ground.top(x, z)
368        {
369            return t;
370        }
371        self.boxes[i].floor_course_y()
372    }
373
374    /// True when `cell` is fixed ground of place `i`: a ring cell of its shell
375    /// at or under the column's ground height. An aloft place has none.
376    #[must_use]
377    pub fn is_fixed(&self, i: usize, cell: [i64; 3]) -> bool {
378        !self.is_aloft(i)
379            && self.in_shell(i, cell)
380            && self.boxes[i].is_ring_column(cell[0], cell[2])
381            && cell[1] <= self.ground_height(i, cell[0], cell[2])
382    }
383
384    /// True when `cell` is **sky ring** of place `i`: a ring cell of an open
385    /// ground place above its floor course — the air its open headroom carries
386    /// beside its play space. It is the one claim that yields to a place hung
387    /// in it (departure 35); its ground (rule 0) and its floor course do not.
388    #[must_use]
389    pub fn is_sky_ring(&self, i: usize, cell: [i64; 3]) -> bool {
390        let b = &self.boxes[i];
391        b.open
392            && !self.is_aloft(i)
393            && b.is_ring_column(cell[0], cell[2])
394            && cell[1] > b.floor_course_y()
395            && cell[1] <= b.top()
396    }
397
398    /// True when `cell` is in place `i`'s claim: in its shell, or in its roof
399    /// zone — except an eaves cell standing in another place's shell (its play
400    /// space, its walls, its lid), where the eave stops (spec-0098 §3).
401    #[must_use]
402    pub fn claims(&self, i: usize, cell: [i64; 3]) -> bool {
403        if self.in_shell(i, cell) {
404            return true;
405        }
406        let b = &self.boxes[i];
407        let Some(zone) = b.roof_zone() else {
408            return false;
409        };
410        if !inside(cell, zone) {
411            return false;
412        }
413        let over_shell = cell[0] >= b.foot[0] - 1
414            && cell[0] <= b.foot[1] + 1
415            && cell[2] >= b.foot[2] - 1
416            && cell[2] <= b.foot[3] + 1;
417        // An eave stops where a neighbour's own shell begins — its play space,
418        // its walls, its lid — as a real eave stops at the wall it meets.
419        over_shell || !(0..self.boxes.len()).any(|k| k != i && self.in_shell(k, cell))
420    }
421
422    /// The places whose claim bounds meet `bounds` — the only places a cell
423    /// inside `bounds` can be claimed by.
424    #[must_use]
425    pub fn near(&self, bounds: Aabb) -> Vec<usize> {
426        (0..self.boxes.len())
427            .filter(|i| intersect(self.claim_bounds(*i), bounds).is_some())
428            .collect()
429    }
430
431    /// **Who writes `cell`** — spec-0098 §2's rule, in its order, over every
432    /// place.
433    #[must_use]
434    pub fn owner(&self, cell: [i64; 3]) -> Owner {
435        let all: Vec<usize> = (0..self.boxes.len()).collect();
436        self.owner_among(cell, &all)
437    }
438
439    /// [`Site::owner`] over `cand` only — every place whose claim could reach
440    /// `cell` must be in it ([`Site::near`]).
441    ///
442    /// 0. a ring cell at or under its ground height is the whole's fixed ground;
443    /// 1. a cell inside a place's play space is that place's;
444    /// 2. a cell exactly one claim covers is that place's;
445    /// 3. a cell several claims cover: in exactly one of their floor courses,
446    ///    that place's (a stacked plane is the upper's floor); else, exactly one
447    ///    of them roofed, that one's (the facade onto an open place); else,
448    ///    the seams the plan allocates between two of them across a plane
449    ///    through the cell agreeing, pair by pair, about which is `a`, the `a`
450    ///    of the first of them in plan order (a designed connection is drawn by
451    ///    its first-named side); else, exactly one of them aloft and every
452    ///    other claiming it only as a ground place's sky ring
453    ///    ([`Site::is_sky_ring`]), the aloft one's (a place hung in a yard's
454    ///    sky owns what it hangs in — spec-0098 §14 departure 35, between 3c
455    ///    and 3d); else contested (3d) — `DW0827`;
456    /// 4. a cell no claim covers is nobody's.
457    #[must_use]
458    pub fn owner_among(&self, cell: [i64; 3], cand: &[usize]) -> Owner {
459        if cand.iter().any(|&i| self.is_fixed(i, cell)) {
460            return Owner::Ground;
461        }
462        if let Some(&i) = cand.iter().find(|&&i| inside(cell, self.boxes[i].space())) {
463            return Owner::Place(self.boxes[i].node.clone());
464        }
465        let claimants: Vec<usize> = cand
466            .iter()
467            .copied()
468            .filter(|&i| self.claims(i, cell))
469            .collect();
470        match claimants.len() {
471            0 => return Owner::Nobody,
472            1 => return Owner::Place(self.boxes[claimants[0]].node.clone()),
473            _ => {}
474        }
475        // 3a — a floor course.
476        let floors: Vec<usize> = claimants
477            .iter()
478            .copied()
479            .filter(|&i| cell[1] == self.boxes[i].floor_course_y())
480            .collect();
481        if floors.len() == 1 {
482            return Owner::Place(self.boxes[floors[0]].node.clone());
483        }
484        // 3b — exactly one roofed.
485        let roofed: Vec<usize> = claimants
486            .iter()
487            .copied()
488            .filter(|&i| !self.boxes[i].open)
489            .collect();
490        if roofed.len() == 1 {
491            return Owner::Place(self.boxes[roofed[0]].node.clone());
492        }
493        // 3c — the connection's `a`. The seams the plan allocates between two
494        // of the claimants across a plane through this cell: where the seams
495        // between one pair disagree about which is `a`, the plan is refused
496        // (rule 3d); where every one names the same `a`, the cell is that
497        // place's — spec-0098 §2's rule as written for two places. At a corner
498        // column where connections led by different places meet (a T-junction
499        // of three places), the cell is the `a` of the first of them in the
500        // plan's own seam order — the designer's order decides, never the
501        // engine (recorded in spec-0098's departures).
502        let names_of: Vec<&NodeId> = claimants.iter().map(|&i| &self.boxes[i].node).collect();
503        let across: Vec<&PlacedSeam> = self
504            .seams
505            .iter()
506            .filter(|s| {
507                names_of.contains(&&s.a)
508                    && names_of.contains(&&s.b)
509                    && cell[s.normal_axis] == s.plane
510            })
511            .collect();
512        let pair_disagrees = across.iter().any(|s| {
513            across.iter().any(|t| {
514                let same_pair = (s.a == t.a && s.b == t.b) || (s.a == t.b && s.b == t.a);
515                same_pair && s.a != t.a
516            })
517        });
518        if let Some(first) = across.first()
519            && !pair_disagrees
520        {
521            return Owner::Place(first.a.clone());
522        }
523        // Between 3c and 3d (spec-0098 §14, departure 35) — a place hung in a
524        // ground place's sky. Where an aloft place's
525        // claim meets only the ring air an open ground place's headroom carries
526        // above its floor course, the hung place's — a stacked plane is the
527        // upper's (3a), and a ground place's open headroom is sky, not a wall
528        // it declared.
529        let aloft: Vec<usize> = claimants
530            .iter()
531            .copied()
532            .filter(|&i| self.is_aloft(i))
533            .collect();
534        if aloft.len() == 1
535            && claimants
536                .iter()
537                .all(|&i| i == aloft[0] || self.is_sky_ring(i, cell))
538        {
539            return Owner::Place(self.boxes[aloft[0]].node.clone());
540        }
541        let mut names: Vec<NodeId> = claimants
542            .iter()
543            .map(|&i| self.boxes[i].node.clone())
544            .collect();
545        names.sort();
546        Owner::Contested(names)
547    }
548
549    /// Every cell of place `i`'s claim, in a fixed order.
550    #[must_use]
551    pub fn claim_cells(&self, i: usize) -> Vec<[i64; 3]> {
552        cells(self.claim_bounds(i))
553            .filter(|c| self.claims(i, *c))
554            .collect()
555    }
556
557    /// Every fixed ground cell of place `i`, with its column's ground height,
558    /// in a fixed order — what the terrain pass writes and the handout hands.
559    /// Empty on an aloft place.
560    #[must_use]
561    pub fn fixed_cells(&self, i: usize) -> Vec<([i64; 3], i64)> {
562        let b = &self.boxes[i];
563        let (lo, hi) = self.shell(i);
564        let mut out = Vec::new();
565        if self.is_aloft(i) {
566            return out;
567        }
568        for x in lo[0]..=hi[0] {
569            for z in lo[2]..=hi[2] {
570                if !b.is_ring_column(x, z) {
571                    continue;
572                }
573                let g = self.ground_height(i, x, z);
574                let from = self.column_bottom(i, x, z).unwrap_or(lo[1]);
575                for y in from..=g.min(hi[1]) {
576                    out.push(([x, y, z], g));
577                }
578            }
579        }
580        out
581    }
582
583    /// The cells several places claim that no rule awards (rule 3d), grouped
584    /// by the places contesting them, in plan order — what `DW0827` refuses —
585    /// and how many shared cells the rule did award, for the binding.
586    #[must_use]
587    pub fn contests(&self) -> (Vec<Contest>, usize) {
588        let mut by: BTreeMap<Vec<NodeId>, BTreeSet<[i64; 3]>> = BTreeMap::new();
589        let mut awarded: BTreeSet<[i64; 3]> = BTreeSet::new();
590        for i in 0..self.boxes.len() {
591            for j in i + 1..self.boxes.len() {
592                let Some(meet) = intersect(self.claim_bounds(i), self.claim_bounds(j)) else {
593                    continue;
594                };
595                let cand = self.near(meet);
596                for c in cells(meet) {
597                    if !(self.claims(i, c) && self.claims(j, c)) {
598                        continue;
599                    }
600                    match self.owner_among(c, &cand) {
601                        Owner::Contested(names) => {
602                            by.entry(names).or_default().insert(c);
603                        }
604                        _ => {
605                            awarded.insert(c);
606                        }
607                    }
608                }
609            }
610        }
611        (
612            by.into_iter()
613                .map(|(n, c)| (n, c.into_iter().collect()))
614                .collect(),
615            awarded.len(),
616        )
617    }
618}
619
620/// Greedy merge of a cell set into disjoint AABBs, in one fixed order: take the
621/// least cell, run along `z`, then grow the run along `y`, then along `x`, each
622/// only while every cell of the grown slab is present. Deterministic (ADR-0006)
623/// and exact — the union of the result is the input.
624#[must_use]
625pub fn merge_cells(cells: &BTreeSet<[i64; 3]>) -> Vec<Aabb> {
626    let mut left = cells.clone();
627    let mut out = Vec::new();
628    while let Some(&lo) = left.iter().next() {
629        let mut hi = lo;
630        while left.contains(&[lo[0], lo[1], hi[2] + 1]) {
631            hi[2] += 1;
632        }
633        'y: loop {
634            let ny = hi[1] + 1;
635            for z in lo[2]..=hi[2] {
636                if !left.contains(&[lo[0], ny, z]) {
637                    break 'y;
638                }
639            }
640            hi[1] = ny;
641        }
642        'x: loop {
643            let nx = hi[0] + 1;
644            for y in lo[1]..=hi[1] {
645                for z in lo[2]..=hi[2] {
646                    if !left.contains(&[nx, y, z]) {
647                        break 'x;
648                    }
649                }
650            }
651            hi[0] = nx;
652        }
653        for x in lo[0]..=hi[0] {
654            for y in lo[1]..=hi[1] {
655                for z in lo[2]..=hi[2] {
656                    left.remove(&[x, y, z]);
657                }
658            }
659        }
660        out.push((lo, hi));
661    }
662    out
663}
664
665/// A run of cells inside a place's frame that the place does not own, and who
666/// does.
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub struct Void {
669    /// Low corner, inclusive, in world coordinates.
670    pub lo: [i64; 3],
671    /// High corner, inclusive.
672    pub hi: [i64; 3],
673    /// Who writes them.
674    pub owner: Owner,
675}
676
677/// A place's owned cells and the voids inside their bounding box, derived from
678/// the whole plan. The one computation behind `Frame::of`.
679#[derive(Debug, Clone, PartialEq, Eq)]
680pub struct Ownership {
681    /// Inclusive low corner of the owned cells' bounding box.
682    pub lo: [i64; 3],
683    /// Inclusive high corner.
684    pub hi: [i64; 3],
685    /// The owned cells, merged.
686    pub owned: Vec<Aabb>,
687    /// How many cells the place owns.
688    pub owned_cells: usize,
689    /// Every cell of the bounding box the place does not own, merged per owner,
690    /// in owner order then cell order.
691    pub voids: Vec<Void>,
692    /// The fixed ground cells inside the bounding box, each with the block the
693    /// whole writes there, in cell order.
694    pub fixed: Vec<([i64; 3], String)>,
695    /// Eaves cells the plan clipped at a neighbour's shell, merged, with
696    /// the neighbour.
697    pub clipped: Vec<(Aabb, NodeId)>,
698}
699
700impl Site<'_> {
701    /// What place `i` owns, and what inside its frame it does not.
702    #[must_use]
703    pub fn ownership(&self, i: usize) -> Ownership {
704        let b = &self.boxes[i];
705        let me = Owner::Place(b.node.clone());
706        let bounds = self.claim_bounds(i);
707        let cand = self.near(bounds);
708        let owned: BTreeSet<[i64; 3]> = self
709            .claim_cells(i)
710            .into_iter()
711            .filter(|c| self.owner_among(*c, &cand) == me)
712            .collect();
713        let (lo, hi) = owned
714            .iter()
715            .fold(([i64::MAX; 3], [i64::MIN; 3]), |(mut lo, mut hi), c| {
716                for a in 0..3 {
717                    lo[a] = lo[a].min(c[a]);
718                    hi[a] = hi[a].max(c[a]);
719                }
720                (lo, hi)
721            });
722        let mut by_owner: BTreeMap<Owner, BTreeSet<[i64; 3]>> = BTreeMap::new();
723        let mut fixed: Vec<([i64; 3], String)> = Vec::new();
724        if !owned.is_empty() {
725            let fcand = self.near((lo, hi));
726            for c in cells((lo, hi)) {
727                if owned.contains(&c) {
728                    continue;
729                }
730                let o = self.owner_among(c, &fcand);
731                if o == Owner::Ground {
732                    let g = fcand
733                        .iter()
734                        .find(|&&k| self.is_fixed(k, c))
735                        .map_or(c[1], |&k| self.ground_height(k, c[0], c[2]));
736                    fixed.push((c, self.ground.ground_block(c, g).to_string()));
737                }
738                by_owner.entry(o).or_default().insert(c);
739            }
740        }
741        let voids: Vec<Void> = by_owner
742            .iter()
743            .flat_map(|(o, cells)| {
744                merge_cells(cells).into_iter().map(move |(lo, hi)| Void {
745                    lo,
746                    hi,
747                    owner: o.clone(),
748                })
749            })
750            .collect();
751        // Eaves the plan clipped: roof-zone cells outside the shell footprint
752        // that lie in a neighbour's shell.
753        let mut clipped_by: BTreeMap<NodeId, BTreeSet<[i64; 3]>> = BTreeMap::new();
754        if let Some(zone) = b.roof_zone() {
755            for c in cells(zone) {
756                let over_shell = c[0] >= b.foot[0] - 1
757                    && c[0] <= b.foot[1] + 1
758                    && c[2] >= b.foot[2] - 1
759                    && c[2] <= b.foot[3] + 1;
760                if over_shell {
761                    continue;
762                }
763                if let Some(k) = (0..self.boxes.len()).find(|&k| k != i && self.in_shell(k, c)) {
764                    clipped_by
765                        .entry(self.boxes[k].node.clone())
766                        .or_default()
767                        .insert(c);
768                }
769            }
770        }
771        let clipped: Vec<(Aabb, NodeId)> = clipped_by
772            .iter()
773            .flat_map(|(n, cells)| merge_cells(cells).into_iter().map(move |r| (r, n.clone())))
774            .collect();
775        Ownership {
776            lo,
777            hi,
778            owned_cells: owned.len(),
779            owned: merge_cells(&owned),
780            voids,
781            fixed,
782            clipped,
783        }
784    }
785}
786
787/// A campaign's resolved plan, owned: what a [`Site`] borrows.
788#[derive(Debug, Clone)]
789pub struct SitePlan {
790    /// The plan's places.
791    pub boxes: Vec<PlacedBox>,
792    /// The plan's connections.
793    pub seams: Vec<PlacedSeam>,
794    /// The site's fill.
795    pub ground: Ground,
796}
797
798impl SitePlan {
799    /// Resolve a campaign's plan.
800    #[must_use]
801    pub fn of(c: &Campaign) -> SitePlan {
802        let mut reads = crate::metrics::Reads::new();
803        let boxes = super::placed_boxes(c, &mut reads);
804        let seams = super::placed_seams(c, &boxes, &mut reads);
805        SitePlan {
806            boxes,
807            seams,
808            ground: Ground::of(c),
809        }
810    }
811
812    /// The ownership view over it.
813    #[must_use]
814    pub fn site(&self) -> Site<'_> {
815        Site::new(&self.boxes, &self.seams, &self.ground)
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::ids::EdgeId;
823    use crate::siteplan::{Crossing, Face, Roof};
824
825    fn a_box(node: &str, foot: [i64; 4], floor: i64, clearance: u32, open: bool) -> PlacedBox {
826        PlacedBox {
827            node: NodeId(node.into()),
828            foot,
829            floor,
830            clearance,
831            open,
832            base: crate::siteplan::Base::Ground,
833            roof: None,
834        }
835    }
836
837    fn seam(a: &str, b: &str, axis: usize, plane: i64) -> PlacedSeam {
838        PlacedSeam {
839            edge: EdgeId(format!("edge/{}-{}", &a[5..], &b[5..])),
840            class: "walk",
841            a: NodeId(a.into()),
842            b: NodeId(b.into()),
843            face: Face::East,
844            normal_axis: axis,
845            plane,
846            opening: ([plane, 64, 2], [plane, 66, 3]),
847            shared: ([plane, 64, 0], [plane, 67, 7]),
848            crossing: Crossing::Portal,
849            rise: 0,
850            stair_in: None,
851            form: "a doorway".into(),
852        }
853    }
854
855    fn solid() -> Ground {
856        Ground::solid("minecraft:stone")
857    }
858
859    /// A house (x 0..7) and the street east of it (x 9..16), one cell apart.
860    fn house_and_street() -> Vec<PlacedBox> {
861        vec![
862            a_box("node/house", [0, 7, 0, 7], 64, 4, false),
863            a_box("node/street", [9, 16, 0, 7], 64, 6, true),
864        ]
865    }
866
867    /// **An aloft seam fixes no earth under it; a grade seam still levels**
868    /// (spec-0098 §14, a correction). Two treehouses whose floors stand 16
869    /// courses over open ground at y 47, joined across the plane x 8 by a
870    /// bridge seam whose sill is the deck at y 64: the ring column under the
871    /// opening is the terrain's ground (y 47), and the cells between it and
872    /// the deck are the owner's, not the whole's. The same pair on ground at
873    /// y 63 — the sill one step over it — levels the column to the sill minus
874    /// one, as before.
875    #[test]
876    fn an_aloft_seam_fixes_no_earth_under_it_and_a_grade_seam_levels() {
877        let boxes = vec![
878            a_box("node/east-house", [0, 7, 0, 7], 64, 4, false),
879            a_box("node/west-house", [9, 16, 0, 7], 64, 4, false),
880        ];
881        let seams = vec![seam("node/east-house", "node/west-house", 0, 8)];
882        let low = Ground::open_flat(47, "minecraft:grass_block", "minecraft:dirt");
883        let site = Site::new(&boxes, &seams, &low);
884        assert_eq!(site.ground_height(0, 8, 2), 47, "the terrain, not the deck");
885        assert!(
886            !site.is_fixed(0, [8, 55, 2]),
887            "no earth column under the bridge"
888        );
889        assert_eq!(
890            site.owner([8, 55, 2]),
891            Owner::Place(NodeId("node/east-house".into())),
892            "the column between terrain and deck is the seam's a"
893        );
894        assert!(
895            site.is_fixed(0, [8, 47, 2]),
896            "the terrain itself is still fixed"
897        );
898        let grade = Ground::open_flat(62, "minecraft:grass_block", "minecraft:dirt");
899        let site = Site::new(&boxes, &seams, &grade);
900        assert_eq!(
901            site.ground_height(0, 8, 2),
902            63,
903            "one step up: levelled to the sill"
904        );
905        assert!(site.is_fixed(0, [8, 63, 2]));
906    }
907
908    #[test]
909    fn the_party_plane_between_a_house_and_a_street_is_the_facade() {
910        let boxes = house_and_street();
911        let g = solid();
912        let site = Site::new(&boxes, &[], &g);
913        assert_eq!(
914            site.owner([8, 65, 3]),
915            Owner::Place(NodeId("node/house".into()))
916        );
917        assert_eq!(
918            site.owner([8, 63, 3]),
919            Owner::Ground,
920            "the ring's ground is fixed"
921        );
922        assert_eq!(
923            site.owner([8, 69, 3]),
924            Owner::Place(NodeId("node/street".into()))
925        );
926        assert_eq!(
927            site.owner([17, 65, 3]),
928            Owner::Place(NodeId("node/street".into()))
929        );
930        assert_eq!(site.owner([30, 65, 3]), Owner::Nobody);
931    }
932
933    #[test]
934    fn two_yards_meet_on_the_side_their_connection_names_first() {
935        let boxes = vec![
936            a_box("node/yard-a", [0, 7, 0, 7], 64, 3, true),
937            a_box("node/yard-b", [9, 16, 0, 7], 64, 3, true),
938        ];
939        let g = solid();
940        let site = Site::new(&boxes, &[], &g);
941        assert!(
942            matches!(site.owner([8, 65, 1]), Owner::Contested(_)),
943            "nothing connects them"
944        );
945        let s = [seam("node/yard-b", "node/yard-a", 0, 8)];
946        let site = Site::new(&boxes, &s, &g);
947        assert_eq!(
948            site.owner([8, 65, 1]),
949            Owner::Place(NodeId("node/yard-b".into()))
950        );
951        let disagree = [
952            seam("node/yard-b", "node/yard-a", 0, 8),
953            seam("node/yard-a", "node/yard-b", 0, 8),
954        ];
955        let site = Site::new(&boxes, &disagree, &g);
956        assert!(matches!(site.owner([8, 65, 1]), Owner::Contested(_)));
957    }
958
959    #[test]
960    fn a_stacked_plane_is_the_upper_floor() {
961        let lower = a_box("node/crypt", [0, 7, 0, 7], 60, 3, false); // top 62, lid 63
962        let upper = a_box("node/chapel", [0, 7, 0, 7], 64, 6, false); // floor course 63
963        let boxes = vec![lower, upper];
964        let g = solid();
965        let site = Site::new(&boxes, &[], &g);
966        assert_eq!(
967            site.owner([3, 63, 3]),
968            Owner::Place(NodeId("node/chapel".into()))
969        );
970    }
971
972    #[test]
973    fn a_roof_zone_is_claimed_and_its_eaves_stop_at_a_neighbour() {
974        let mut boxes = house_and_street();
975        boxes[0].roof = Some(Roof {
976            courses: 4,
977            eaves: 1,
978        });
979        let g = solid();
980        let site = Site::new(&boxes, &[], &g);
981        assert_eq!(boxes[0].roof_zone(), Some(([-2, 68, -2], [9, 72, 9])));
982        assert!(!site.claims(0, [9, 68, 3]));
983        assert_eq!(
984            site.owner([9, 68, 3]),
985            Owner::Place(NodeId("node/street".into()))
986        );
987        assert!(site.claims(0, [9, 71, 3]));
988        assert_eq!(
989            site.owner([9, 71, 3]),
990            Owner::Place(NodeId("node/house".into()))
991        );
992        let o = site.ownership(0);
993        assert_eq!(o.lo, [-2, 63, -2]);
994        assert_eq!(o.hi, [9, 72, 9]);
995        assert!(o.clipped.iter().any(|(_, n)| n.0 == "node/street"));
996        let owned: BTreeSet<[i64; 3]> = o.owned.iter().flat_map(|a| cells(*a)).collect();
997        assert_eq!(owned.len(), o.owned_cells, "the merge is exact");
998        for v in &o.voids {
999            assert!(!owned.contains(&v.lo));
1000        }
1001    }
1002
1003    /// **Criterion 16, at the ownership rule.** Two roofed places exactly one
1004    /// cell apart with no connection share the column their rings stand in,
1005    /// and no rule awards it (rule 3d — `DW0827` refuses the plan). Each remedy
1006    /// the refusal names parts or awards it: a seam between them awards the
1007    /// plane to its `a`; standing them two apart leaves their claims disjoint,
1008    /// the gap the site's fill; one place has no pair.
1009    #[test]
1010    fn two_roofed_places_one_apart_are_contested_and_each_remedy_parts_them() {
1011        let g = solid();
1012        let a = a_box("node/west-room", [0, 7, 0, 7], 64, 4, false);
1013        let b = a_box("node/east-room", [9, 16, 0, 7], 64, 4, false);
1014        let boxes = vec![a.clone(), b.clone()];
1015        let site = Site::new(&boxes, &[], &g);
1016        let (contests, _) = site.contests();
1017        assert_eq!(contests.len(), 1, "one pair contests");
1018        assert!(!contests[0].1.is_empty(), "and the claims really intersect");
1019        assert!(
1020            contests[0].1.iter().all(|c| c[0] == 8),
1021            "in the shared ring column"
1022        );
1023
1024        let s = [seam("node/west-room", "node/east-room", 0, 8)];
1025        let site = Site::new(&boxes, &s, &g);
1026        assert!(
1027            site.contests().0.is_empty(),
1028            "a connection awards the plane"
1029        );
1030        assert_eq!(
1031            site.owner([8, 65, 5]),
1032            Owner::Place(NodeId("node/west-room".into()))
1033        );
1034
1035        let apart = vec![
1036            a.clone(),
1037            a_box("node/east-room", [10, 17, 0, 7], 64, 4, false),
1038        ];
1039        let site = Site::new(&apart, &[], &g);
1040        assert!(site.contests().0.is_empty(), "two apart, nothing is shared");
1041        assert_eq!(
1042            site.owner([9, 65, 5]),
1043            Owner::Place(NodeId("node/east-room".into()))
1044        );
1045
1046        let one = vec![a_box("node/one-room", [0, 16, 0, 7], 64, 4, false)];
1047        assert!(Site::new(&one, &[], &g).contests().0.is_empty());
1048    }
1049
1050    fn hung(mut b: PlacedBox, n: u32) -> PlacedBox {
1051        b.base = crate::siteplan::Base::Aloft(n);
1052        b
1053    }
1054
1055    fn grass(top: i64) -> Ground {
1056        Ground::open_flat(top, "minecraft:grass_block", "minecraft:dirt")
1057    }
1058
1059    /// **A hall over a cellar keeps its ground beside the cellar** (spec-0098
1060    /// §14, departure 2, per column). The hall (x/z 0..15, floor 64) stands on
1061    /// open ground at y 59 over a cellar (x/z 2..9, lid at 61): only the
1062    /// columns over the cellar's shell stop one course above its lid; every
1063    /// other column reaches the terrain, so the hall's ring keeps its fixed
1064    /// ground and owns the earth beside the cellar. Raised as one box, the
1065    /// hall's bottom would be 62 everywhere and its fixed ground would vanish.
1066    #[test]
1067    fn a_hall_over_a_cellar_keeps_its_ground_beside_the_cellar() {
1068        let boxes = vec![
1069            a_box("node/hall", [0, 15, 0, 15], 64, 8, false),
1070            a_box("node/cellar", [2, 9, 2, 9], 58, 3, false),
1071        ];
1072        let g = grass(59);
1073        let site = Site::new(&boxes, &[], &g);
1074        assert_eq!(site.column_bottom(0, 5, 5), Some(62), "over the cellar");
1075        assert_eq!(site.column_bottom(0, 12, 12), Some(59), "beside it");
1076        assert_eq!(site.bottom(0), 59, "the shell reaches the lowest column");
1077        assert!(
1078            !site.fixed_cells(0).is_empty(),
1079            "the hall's ring keeps its fixed ground"
1080        );
1081        assert!(site.is_fixed(0, [-1, 59, 5]));
1082        assert_eq!(
1083            site.owner([12, 60, 12]),
1084            Owner::Place(NodeId("node/hall".into())),
1085            "the earth beside the cellar is the hall's"
1086        );
1087        assert_eq!(
1088            site.owner([5, 61, 5]),
1089            Owner::Place(NodeId("node/cellar".into())),
1090            "the cellar's lid is the cellar's"
1091        );
1092        assert!(site.contests().0.is_empty());
1093    }
1094
1095    /// **An aloft place owns no ground** (spec-0098 §2). A bridge deck at floor
1096    /// 70 hung one course under its floor course over open ground at y 59:
1097    /// its claim's bottom is y 68 everywhere, it has no fixed ring, and the
1098    /// air and terrain under it are nobody's — the commons. Terrain reaching
1099    /// y 68 under it is the earth standing in the claim (`DW0990`).
1100    #[test]
1101    fn an_aloft_place_owns_no_ground_and_the_space_under_it_is_the_commons() {
1102        let boxes = vec![hung(a_box("node/bridge", [0, 7, 0, 3], 70, 3, true), 1)];
1103        let g = grass(59);
1104        let site = Site::new(&boxes, &[], &g);
1105        assert_eq!(site.bottom(0), 68);
1106        assert_eq!(site.column_bottom(0, -1, -1), Some(68));
1107        assert!(site.fixed_cells(0).is_empty(), "no fixed ring");
1108        assert!(!site.is_fixed(0, [-1, 59, 0]));
1109        assert_eq!(site.owner([3, 60, 1]), Owner::Nobody, "under the deck");
1110        assert_eq!(site.owner([3, 59, 1]), Owner::Nobody, "the terrain");
1111        assert_eq!(
1112            site.owner([3, 68, 1]),
1113            Owner::Place(NodeId("node/bridge".into())),
1114            "the underside course"
1115        );
1116        assert!(site.aloft_in_earth(0).is_empty());
1117        let high = grass(68);
1118        let site = Site::new(&boxes, &[], &high);
1119        assert_eq!(
1120            site.aloft_in_earth(0).len(),
1121            10 * 6,
1122            "every footprint and ring column"
1123        );
1124        let solid = solid();
1125        assert!(
1126            Site::new(&boxes, &[], &solid).aloft_in_earth(0).is_empty(),
1127            "a solid site's rock is carved by every claim"
1128        );
1129    }
1130
1131    /// **A gantry over a yard meets it at the yard's open headroom**
1132    /// (spec-0098 §2). The yard (floor 64, `open: 4`) claims air to y 67; the
1133    /// gantry (floor 69, aloft 0) claims from its floor course, y 68 — the
1134    /// cuboids touch and do not overlap. Hung one course lower, the gantry's
1135    /// underside reaches the yard's air and its ring: the yard's play space
1136    /// stays the yard's (rule 1), and the ring air the yard's headroom carries
1137    /// is the gantry's, hung in it (departure 35) — nothing is contested.
1138    #[test]
1139    fn a_gantry_over_a_yard_meets_it_at_the_yards_headroom() {
1140        let yard = a_box("node/yard", [0, 7, 0, 7], 64, 4, true);
1141        let gantry = a_box("node/gantry", [2, 5, -6, 13], 69, 3, true);
1142        let g = grass(63);
1143        let boxes = vec![yard.clone(), hung(gantry.clone(), 0)];
1144        let site = Site::new(&boxes, &[], &g);
1145        assert!(site.contests().0.is_empty(), "the cuboids touch");
1146        assert_eq!(
1147            site.owner([3, 67, 3]),
1148            Owner::Place(NodeId("node/yard".into()))
1149        );
1150        assert_eq!(
1151            site.owner([3, 68, 3]),
1152            Owner::Place(NodeId("node/gantry".into()))
1153        );
1154        let boxes = vec![yard, hung(gantry, 1)];
1155        let site = Site::new(&boxes, &[], &g);
1156        assert!(
1157            site.contests().0.is_empty(),
1158            "the hung place takes the sky ring"
1159        );
1160        assert_eq!(
1161            site.owner([3, 67, -1]),
1162            Owner::Place(NodeId("node/gantry".into())),
1163            "the yard's ring air under the gantry"
1164        );
1165        assert_eq!(
1166            site.owner([3, 67, 3]),
1167            Owner::Place(NodeId("node/yard".into())),
1168            "the yard's own sky"
1169        );
1170    }
1171
1172    /// **A place hung in a yard's sky owns what it hangs in** (departure 35,
1173    /// spec-0098 §14). The Treehouse Camp's shape: a glade (ground, open
1174    /// headroom to y 84 so its ladder reaches the house floor over it), a
1175    /// house hung over it (floor 86) and a bridge hung off the house's east
1176    /// face (floor 80, one underside course), the seam between house and
1177    /// bridge on the plane x 22 — the glade's own ring column. The bridge's
1178    /// underside (y 78) and its end ring (y 80..84) stand in the glade's ring
1179    /// air; no seam joins glade and bridge. Each such cell is the bridge's,
1180    /// and nothing is contested. Only sky ring yields: two open
1181    /// ground yards one apart still contest their shared ring, and the
1182    /// glade's ground and floor course never yield.
1183    #[test]
1184    fn a_place_hung_in_a_glades_sky_owns_what_it_hangs_in() {
1185        let g = grass(71);
1186        let glade = a_box("node/glade", [0, 21, 0, 21], 72, 13, true);
1187        let house = hung(a_box("node/house", [-2, 21, -2, 21], 86, 8, true), 0);
1188        let bridge = hung(a_box("node/bridge", [23, 44, 6, 8], 80, 9, true), 1);
1189        let boxes = vec![glade, house, bridge];
1190        let seams = vec![seam("node/house", "node/bridge", 0, 22)];
1191        let site = Site::new(&boxes, &seams, &g);
1192        let (contests, _) = site.contests();
1193        assert!(contests.is_empty(), "{contests:?}");
1194        for y in [78, 80, 81, 82, 83, 84] {
1195            for z in 5..=9 {
1196                assert!(site.is_sky_ring(0, [22, y, z]));
1197                assert_eq!(
1198                    site.owner([22, y, z]),
1199                    Owner::Place(NodeId("node/bridge".into())),
1200                    "[22, {y}, {z}]"
1201                );
1202            }
1203        }
1204        assert_eq!(
1205            site.owner([22, 72, 7]),
1206            Owner::Place(NodeId("node/glade".into())),
1207            "the glade's ring under the bridge is still its own"
1208        );
1209        assert!(
1210            !site.is_sky_ring(0, [22, 71, 7]),
1211            "the floor course is not sky"
1212        );
1213        assert!(
1214            !site.is_sky_ring(0, [10, 78, 10]),
1215            "the play space is not ring"
1216        );
1217        assert!(
1218            !site.is_sky_ring(2, [22, 82, 7]),
1219            "a hung place's ring is not sky"
1220        );
1221    }
1222
1223    #[test]
1224    fn merge_is_exact_and_deterministic() {
1225        let cells: BTreeSet<[i64; 3]> = [[0, 0, 0], [0, 0, 1], [1, 0, 0], [1, 0, 1], [5, 5, 5]]
1226            .into_iter()
1227            .collect();
1228        let m = merge_cells(&cells);
1229        assert_eq!(m, vec![([0, 0, 0], [1, 0, 1]), ([5, 5, 5], [5, 5, 5])]);
1230        assert_eq!(merge_cells(&cells), m);
1231    }
1232}