delvewright_dsl/siteplan/ground.rs
1//! **What the whole writes where no place does** (spec-0098 §2b, §2c): the
2//! site's `fill`, resolved once into the one answer every reader asks of it —
3//! how high the terrain stands in a column, and which block a cell of the whole
4//! holds.
5//!
6//! The claim (`claim.rs`), the blockout derivation and the handout all read
7//! this, so the ground a piece is handed, the ground the derivation lays and the
8//! ground the crack check measures are one computation.
9
10use super::*;
11
12/// The site plan's heightmap as a loader read it.
13///
14/// The DSL reads no file: the loader decodes the image the plan names and
15/// attaches it to the [`Campaign`] (`Campaign::heightmap`), and everything here
16/// reads that. Grey values only, row-major over `z` then `x`, so
17/// `grey[pz * width + px]` is column `(region.min.x + px, region.min.z + pz)`.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum HeightmapRead {
20 /// The decoded image.
21 Read {
22 /// The path the plan named, as read.
23 path: String,
24 /// Pixels along `x`.
25 width: u32,
26 /// Pixels along `z`.
27 depth: u32,
28 /// One grey value per pixel.
29 grey: Vec<u8>,
30 },
31 /// The image could not be read or decoded; the loader's words.
32 Unreadable {
33 /// The path the plan named.
34 path: String,
35 /// Why it could not be read.
36 why: String,
37 },
38}
39
40/// The fill, resolved: what the whole holds wherever no place claims a cell.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Ground {
43 kind: GroundKind,
44 region_min: [i64; 3],
45 region_max: [i64; 3],
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
49enum GroundKind {
50 /// No plan, or a plan whose fill does not parse — nothing is written.
51 None,
52 Solid {
53 block: String,
54 },
55 Open {
56 surface: String,
57 below: String,
58 tops: Tops,
59 },
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63enum Tops {
64 /// One surface `y` everywhere.
65 Flat(i64),
66 /// A decoded heightmap: `tops[pz * width + px]`.
67 Map {
68 width: i64,
69 depth: i64,
70 tops: Vec<i64>,
71 },
72 /// A terrain this campaign cannot resolve — an undeclared datum (`DW0112`)
73 /// or a heightmap not read or not region-shaped (`DW0826`). Refused at
74 /// validation; anything asked of it answers as though there were no
75 /// terrain, which only a refused plan ever sees.
76 Unresolved,
77}
78
79/// The surface `y` a heightmap pixel stands for: `base_y + value × range / 255`,
80/// integer division.
81#[must_use]
82pub fn pixel_top(base_y: i64, range: u32, value: u8) -> i64 {
83 base_y + (i64::from(value) * i64::from(range)) / 255
84}
85
86impl Ground {
87 /// The ground a campaign's site plan declares.
88 #[must_use]
89 pub fn of(c: &Campaign) -> Ground {
90 let Some(plan) = c.site_plan.as_ref().map(|p| &p.content) else {
91 return Ground {
92 kind: GroundKind::None,
93 region_min: [0; 3],
94 region_max: [-1; 3],
95 };
96 };
97 let region_min = plan.region.min;
98 let region_max = plan.region.max();
99 let kind = match &plan.fill {
100 Fill::Solid { block } => GroundKind::Solid {
101 block: block.clone(),
102 },
103 Fill::Open {
104 terrain,
105 surface,
106 below,
107 } => GroundKind::Open {
108 surface: surface.clone(),
109 below: below.clone(),
110 tops: Self::tops(plan, terrain, c.heightmap.as_ref()),
111 },
112 };
113 Ground {
114 kind,
115 region_min,
116 region_max,
117 }
118 }
119
120 fn tops(plan: &SitePlanContent, terrain: &Terrain, read: Option<&HeightmapRead>) -> Tops {
121 match terrain {
122 Terrain::Flat { datum } => plan
123 .datums
124 .iter()
125 .find(|d| &d.id == datum)
126 .map_or(Tops::Unresolved, |d| Tops::Flat(d.y - 1)),
127 Terrain::Heightmap {
128 heightmap,
129 base_y,
130 range,
131 } => {
132 let Some(HeightmapRead::Read {
133 path,
134 width,
135 depth,
136 grey,
137 }) = read
138 else {
139 return Tops::Unresolved;
140 };
141 let (w, d) = (
142 i64::from(plan.region.extent[0].get()),
143 i64::from(plan.region.extent[2].get()),
144 );
145 if path != heightmap || i64::from(*width) != w || i64::from(*depth) != d {
146 return Tops::Unresolved;
147 }
148 Tops::Map {
149 width: w,
150 depth: d,
151 tops: grey
152 .iter()
153 .map(|v| pixel_top(*base_y, *range, *v))
154 .collect(),
155 }
156 }
157 }
158 }
159
160 /// A `solid` site of `block`, standing alone — for a caller that has no
161 /// campaign (a test, a probe).
162 #[must_use]
163 pub fn solid(block: &str) -> Ground {
164 Ground {
165 kind: GroundKind::Solid {
166 block: block.to_string(),
167 },
168 region_min: [i64::MIN / 4; 3],
169 region_max: [i64::MAX / 4; 3],
170 }
171 }
172
173 /// An `open` site over one flat surface `y`, standing alone.
174 #[must_use]
175 pub fn open_flat(top: i64, surface: &str, below: &str) -> Ground {
176 Ground {
177 kind: GroundKind::Open {
178 surface: surface.to_string(),
179 below: below.to_string(),
180 tops: Tops::Flat(top),
181 },
182 region_min: [i64::MIN / 4; 3],
183 region_max: [i64::MAX / 4; 3],
184 }
185 }
186
187 /// True for an `open` site.
188 #[must_use]
189 pub fn is_open(&self) -> bool {
190 matches!(self.kind, GroundKind::Open { .. })
191 }
192
193 /// True when the plan declares a fill this ground answers for.
194 #[must_use]
195 pub fn is_declared(&self) -> bool {
196 !matches!(self.kind, GroundKind::None)
197 }
198
199 /// The terrain's surface `y` in column `(x, z)`, on an `open` site whose
200 /// terrain resolved. A column outside the region reads the nearest edge
201 /// column, so a ring standing one cell past the region's rim still meets
202 /// the ground the rim has (`DW0826` refuses a claim that leaves the region).
203 #[must_use]
204 pub fn top(&self, x: i64, z: i64) -> Option<i64> {
205 let GroundKind::Open { tops, .. } = &self.kind else {
206 return None;
207 };
208 match tops {
209 Tops::Flat(y) => Some(*y),
210 Tops::Map { width, depth, tops } => {
211 let px = (x - self.region_min[0]).clamp(0, width - 1);
212 let pz = (z - self.region_min[2]).clamp(0, depth - 1);
213 tops.get(usize::try_from(pz * width + px).ok()?).copied()
214 }
215 Tops::Unresolved => None,
216 }
217 }
218
219 /// The block the whole holds at `cell` where nothing — no place, no volume
220 /// — claims it; `None` is air.
221 #[must_use]
222 pub fn fill_block(&self, cell: [i64; 3]) -> Option<&str> {
223 match &self.kind {
224 GroundKind::None => None,
225 GroundKind::Solid { block } => Some(block),
226 GroundKind::Open { surface, below, .. } => {
227 let top = self.top(cell[0], cell[2])?;
228 match cell[1].cmp(&top) {
229 std::cmp::Ordering::Greater => None,
230 std::cmp::Ordering::Equal => Some(surface),
231 std::cmp::Ordering::Less => Some(below),
232 }
233 }
234 }
235 }
236
237 /// The block a **fixed ground** cell holds (spec-0098 §2 rule 0) — a ring
238 /// cell at or under its ground height `g`: the `surface` block at `g`,
239 /// `below` under it on an `open` site; the `solid` fill's block on a solid
240 /// one. Volumes never reach the ring's ground: it is the terrain continued
241 /// to the plot's edge, block for block.
242 #[must_use]
243 pub fn ground_block(&self, cell: [i64; 3], g: i64) -> &str {
244 match &self.kind {
245 GroundKind::None => "minecraft:air",
246 GroundKind::Solid { block } => block,
247 GroundKind::Open { surface, below, .. } => {
248 if cell[1] >= g {
249 surface
250 } else {
251 below
252 }
253 }
254 }
255 }
256
257 /// The block a volume of `role` is made of when it names none: the fill's
258 /// own block of that kind (spec-0098 §2b). `None` is air.
259 #[must_use]
260 pub fn volume_block(&self, role: VolumeRole, cell: [i64; 3], vol_top: i64) -> Option<&str> {
261 match (role, &self.kind) {
262 (VolumeRole::Clearance, _) | (_, GroundKind::None) => None,
263 (_, GroundKind::Solid { block }) => Some(block),
264 (VolumeRole::Massif, GroundKind::Open { below, .. }) => Some(below),
265 (VolumeRole::Ground, GroundKind::Open { surface, below, .. }) => {
266 Some(if cell[1] == vol_top { surface } else { below })
267 }
268 }
269 }
270
271 /// The region's inclusive corners.
272 #[must_use]
273 pub fn region(&self) -> ([i64; 3], [i64; 3]) {
274 (self.region_min, self.region_max)
275 }
276}